From 16916b1c302cdebaaf2db61cfe981e8befc63576 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Tue, 24 Mar 2026 14:44:27 +0000 Subject: [PATCH 01/19] Squashed 'klippy/extras/Happy-Hare/' content from commit a880ac0ad git-subtree-dir: klippy/extras/Happy-Hare git-subtree-split: a880ac0adccf532cf98a20567c47e10efdee5576 --- .github/CONTRIBUTING.md | 27 + .github/ISSUE_TEMPLATE/bug_report.yml | 79 + .github/ISSUE_TEMPLATE/config.yml | 6 + .github/ISSUE_TEMPLATE/feature_request.yml | 77 + .github/workflows/needs-info-stale.yml | 25 + .github/workflows/needs-info.yml | 61 + .github/workflows/stale.yml | 33 + .gitignore | 2 + LICENSE | 674 ++ README.md | 169 + components/mmu_server.py | 1282 +++ config/addons/README.md | 7 + config/addons/blobifier.cfg | 1053 +++ config/addons/blobifier_hw.cfg | 28 + config/addons/mmu_eject_buttons.cfg | 35 + config/addons/mmu_eject_buttons_hw.cfg | 21 + config/addons/mmu_erec_cutter.cfg | 91 + config/addons/mmu_erec_cutter_hw.cfg | 10 + config/base/mmu.cfg | 123 + config/base/mmu.cfg.kms | 33 + config/base/mmu.cfg.vvd | 35 + config/base/mmu_cut_tip.cfg | 296 + config/base/mmu_form_tip.cfg | 178 + config/base/mmu_hardware.cfg | 485 ++ config/base/mmu_hardware.cfg.kms | 477 + config/base/mmu_hardware.cfg.vvd | 401 + config/base/mmu_heater_vent.cfg | 56 + config/base/mmu_leds.cfg | 89 + config/base/mmu_macro_vars.cfg | 485 ++ config/base/mmu_parameters.cfg | 820 ++ config/base/mmu_parameters.cfg.rs | 795 ++ config/base/mmu_parameters.cfg.ss | 783 ++ config/base/mmu_parameters.cfg.vs | 756 ++ config/base/mmu_purge.cfg | 95 + config/base/mmu_sequence.cfg | 665 ++ config/base/mmu_software.cfg | 567 ++ config/base/mmu_state.cfg | 124 + config/mmu_vars.cfg | 8 + config/optional/client_macros.cfg | 132 + config/optional/mmu_menu.cfg | 146 + extras/.pylintrc | 12 + extras/mmu/__init__.py | 21 + extras/mmu/mmu.py | 9081 ++++++++++++++++++++ extras/mmu/mmu_calibration_manager.py | 560 ++ extras/mmu/mmu_environment_manager.py | 1158 +++ extras/mmu/mmu_extruder_monitor.py | 153 + extras/mmu/mmu_led_manager.py | 747 ++ extras/mmu/mmu_logger.py | 80 + extras/mmu/mmu_selector.py | 2204 +++++ extras/mmu/mmu_sensor_manager.py | 322 + extras/mmu/mmu_shared.py | 38 + extras/mmu/mmu_sync_controller.py | 1692 ++++ extras/mmu/mmu_sync_controller.py3 | 1659 ++++ extras/mmu/mmu_sync_feedback_manager.py | 896 ++ extras/mmu/mmu_test.py | 864 ++ extras/mmu/mmu_utils.py | 150 + extras/mmu_encoder.py | 310 + extras/mmu_espooler.py | 642 ++ extras/mmu_led_effect.py | 1722 ++++ extras/mmu_leds.py | 206 + extras/mmu_machine.py | 1357 +++ extras/mmu_sensors.py | 808 ++ extras/mmu_servo.py | 115 + install.sh | 2919 +++++++ installer-dev/.gitignore | 1 + installer-dev/Dockerfile | 7 + installer-dev/README.md | 27 + installer-dev/docker-compose.yaml | 19 + installer-dev/entrypoint.sh | 12 + moonraker_update.txt | 11 + pin_defs | 870 ++ test/__init__.py | 0 test/components/__init__.py | 0 test/components/test_mmu_server.py | 88 + test/runner.sh | 12 + test/support/no_toolchange.orig.gcode | 21 + test/support/toolchange.orig.gcode | 33 + utils/plot_sync_feedback.sh | 24 + utils/sim_sync_feedback.sh | 33 + utils/sync_feedback_sim.py | 2027 +++++ 80 files changed, 42130 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/needs-info-stale.yml create mode 100644 .github/workflows/needs-info.yml create mode 100644 .github/workflows/stale.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 components/mmu_server.py create mode 100644 config/addons/README.md create mode 100644 config/addons/blobifier.cfg create mode 100644 config/addons/blobifier_hw.cfg create mode 100644 config/addons/mmu_eject_buttons.cfg create mode 100644 config/addons/mmu_eject_buttons_hw.cfg create mode 100644 config/addons/mmu_erec_cutter.cfg create mode 100644 config/addons/mmu_erec_cutter_hw.cfg create mode 100644 config/base/mmu.cfg create mode 100644 config/base/mmu.cfg.kms create mode 100644 config/base/mmu.cfg.vvd create mode 100644 config/base/mmu_cut_tip.cfg create mode 100644 config/base/mmu_form_tip.cfg create mode 100644 config/base/mmu_hardware.cfg create mode 100644 config/base/mmu_hardware.cfg.kms create mode 100644 config/base/mmu_hardware.cfg.vvd create mode 100644 config/base/mmu_heater_vent.cfg create mode 100644 config/base/mmu_leds.cfg create mode 100644 config/base/mmu_macro_vars.cfg create mode 100644 config/base/mmu_parameters.cfg create mode 100644 config/base/mmu_parameters.cfg.rs create mode 100644 config/base/mmu_parameters.cfg.ss create mode 100644 config/base/mmu_parameters.cfg.vs create mode 100644 config/base/mmu_purge.cfg create mode 100644 config/base/mmu_sequence.cfg create mode 100644 config/base/mmu_software.cfg create mode 100644 config/base/mmu_state.cfg create mode 100644 config/mmu_vars.cfg create mode 100644 config/optional/client_macros.cfg create mode 100644 config/optional/mmu_menu.cfg create mode 100644 extras/.pylintrc create mode 100644 extras/mmu/__init__.py create mode 100644 extras/mmu/mmu.py create mode 100644 extras/mmu/mmu_calibration_manager.py create mode 100644 extras/mmu/mmu_environment_manager.py create mode 100644 extras/mmu/mmu_extruder_monitor.py create mode 100644 extras/mmu/mmu_led_manager.py create mode 100644 extras/mmu/mmu_logger.py create mode 100644 extras/mmu/mmu_selector.py create mode 100644 extras/mmu/mmu_sensor_manager.py create mode 100644 extras/mmu/mmu_shared.py create mode 100644 extras/mmu/mmu_sync_controller.py create mode 100644 extras/mmu/mmu_sync_controller.py3 create mode 100644 extras/mmu/mmu_sync_feedback_manager.py create mode 100644 extras/mmu/mmu_test.py create mode 100644 extras/mmu/mmu_utils.py create mode 100644 extras/mmu_encoder.py create mode 100644 extras/mmu_espooler.py create mode 100644 extras/mmu_led_effect.py create mode 100644 extras/mmu_leds.py create mode 100644 extras/mmu_machine.py create mode 100644 extras/mmu_sensors.py create mode 100644 extras/mmu_servo.py create mode 100755 install.sh create mode 100644 installer-dev/.gitignore create mode 100644 installer-dev/Dockerfile create mode 100644 installer-dev/README.md create mode 100644 installer-dev/docker-compose.yaml create mode 100755 installer-dev/entrypoint.sh create mode 100644 moonraker_update.txt create mode 100644 pin_defs create mode 100644 test/__init__.py create mode 100644 test/components/__init__.py create mode 100644 test/components/test_mmu_server.py create mode 100755 test/runner.sh create mode 100644 test/support/no_toolchange.orig.gcode create mode 100644 test/support/toolchange.orig.gcode create mode 100755 utils/plot_sync_feedback.sh create mode 100755 utils/sim_sync_feedback.sh create mode 100644 utils/sync_feedback_sim.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000000..5c5fbf695568 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,27 @@ +## How to contribute to Happy Hare + +#### **Do you need help with your setup?** + +* Please ask your questions in the [Discord server](https://discord.gg/HXEHUb9W) instead. Github issues is meant for bugs and feature requests, not your specific setup problems. + +#### **Did you find a bug?** + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/moggieuk/Happy-Hare/issues). + +* If you're unable to find an issue addressing the problem, [open a new one](https://github.com/rails/rails/issues/new). Be sure to include a **title and clear description**, as much **relevant information** as possible and **log files**. + +#### **Did you write a patch that fixes a bug?** + +* Open a new GitHub pull request with the patch. + +* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. + +#### **Do you intend to add a new feature or change an existing one?** + +* Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. + +* Changes that break existing setups will probably be rejected. + +Thanks! :heart: :heart: :heart: + +- Moggieuk diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000000..2e3c4d97fcda --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,79 @@ +name: Bug report +description: Report a bug with enough detail for us to reproduce it +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: "Thanks! Please complete all required fields." + + - type: input + id: summary + attributes: + label: Summary + placeholder: "One-sentence description" + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: "Numbered steps are best" + placeholder: "1) ... 2) ... 3) ..." + validations: + required: false + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: false + + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: false + + - type: dropdown + id: mmu_type + attributes: + label: MMU Type + options: ["3D Chameleon", "3MS", "AngryBeaver", "BoxTurtle", "EMU", "ERCF", "KMS", "MMX", "QuattroBox", "NightOwl", "Pico", "Prusa", "Tradrack", "ViViD", "Other/Unknown"] + validations: + required: true + + - type: input + id: hh_version + attributes: + label: Happy Hare version in '3.1.4-16' format + validations: + required: true + + - type: input + id: klipper_version + attributes: + label: Klipper version in '0.13.0-121' format + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant logs (`mmu.log`) + description: "The `mmu.log` is almost always needed. Optional if a klipper error, please attach `klippy.log`" + render: shell + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing issues + required: true + - label: I can reproduce with the latest version of Klipper and Happy Hare + required: false + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..838720f0970b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a usage question (join Happy Hare Discord) + url: https://discord.gg/98TYYUf6f2 + about: Q&A and troubleshooting belong in Discussions + diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000000..a8cb4bb9aa6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,77 @@ +name: Feature request +description: Suggest an idea or improvement +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for contributing! Please complete all required fields. + + - type: input + id: summary + attributes: + label: Summary + description: One-sentence description of the request + placeholder: "Add a concise summary" + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem statement + description: What problem are you trying to solve? Who is impacted and how often? + placeholder: "When __, I want __ so that __." + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution / approach + description: Describe the idea. Include UX flow, API shape, or UI sketches if relevant. + placeholder: "Explain the solution and why it helps." + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority / Impact + options: + - P0 – Critical + - P1 – High + - P2 – Medium + - P3 – Low + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Area(s) of the product + description: Pick one or more + multiple: true + options: + - HappyHare (Klipper or Moonraker) + - Mainsail UI + - Fluidd UI + - KlipperScreen UI + - Documentation + - Other / Don't Know + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: Have you running the lastest Happy Hare release + required: true + - label: I searched existing issues + required: true + - label: I have asked on the Happy Hare Discord + required: false + - label: I’m willing to contribute a PR + required: false diff --git a/.github/workflows/needs-info-stale.yml b/.github/workflows/needs-info-stale.yml new file mode 100644 index 000000000000..d97a72259074 --- /dev/null +++ b/.github/workflows/needs-info-stale.yml @@ -0,0 +1,25 @@ +name: Close unresolved needs-info issues +on: + workflow_dispatch: # manual for now +# schedule: +# - cron: "30 1 * * *" # daily UTC +permissions: + issues: write +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v10 + with: + # Only look at issues that are marked as needs-info + only-issue-labels: "needs-info" + # Treat "needs-info" as the stale label (no extra label) + stale-issue-label: "needs-info" + # Comment right away, then close in 7 days if still inactive + days-before-stale: 0 + days-before-issue-close: 7 + stale-issue-message: > + This issue needs more info. Please update the top comment with the requested details. + close-issue-message: > + Closing due to missing information. Please reopen after updating the details. + diff --git a/.github/workflows/needs-info.yml b/.github/workflows/needs-info.yml new file mode 100644 index 000000000000..0f13c4c15bbe --- /dev/null +++ b/.github/workflows/needs-info.yml @@ -0,0 +1,61 @@ +name: Needs info check +on: + workflow_dispatch: # manual for now +# issues: +# types: [opened, edited] + +jobs: + gate: + if: ${{ github.event.issue.user.type != 'Bot' }} + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const body = (issue.body || ""); + const lc = body.toLowerCase(); + + // Simple heuristics: length + must-have sections/words + const required = ["summary", "steps to reproduce", "expected", "actual", "mmu-type", "hh-version", "klipper-version"]; + const missing = required.filter(s => !lc.includes(s)); + + const insufficient = body.length < 100 || missing.length > 0; + + // Helpers + const hasLabel = (name) => + (issue.labels || []).some(l => (l.name || l) === name); + + if (insufficient && !hasLabel("needs-info")) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: issue.number, labels: ["needs-info"] + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, + body: +`Thanks! We need a bit more detail${missing.length ? ` (missing: ${missing.join(", ")})` : ""}. + +Please edit the top comment to include: +- Concise summary +- Exact steps to reproduce +- Expected vs. Actual +- MMU Type (important) +- Happy-Hare version +- Klipper version + +Once updated, this label will be removed automatically.` + }); + } else if (!insufficient && hasLabel("needs-info")) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: issue.number, name: "needs-info" + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, + body: "Thanks for the details! Removed `needs-info`." + }); + } + diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000000..33f32e6dc7ab --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,33 @@ +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '0 */4 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-pr-stale: 180 + days-before-issue-stale: 30 + days-before-close: 14 + remove-stale-when-updated: true + any-of-labels: believe fixed / answered, more info needed, wontfix, incomplete + stale-issue-message: "This issue is stale because it has been open for over 30 days with no activity. It will be closed in 14 days automatically unless there is activity." + close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." + stale-issue-label: 'stale' + stale-pr-message: "This PR is stale because it has been open for 180 days with no activity. It will be closed in 14 days automatically unless there is activity." + close-pr-message: "This PR was closed because it has been inactive for 14 days since being marked as stale." diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..23336f766544 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +my_*.cfg +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000000..f288702d2fa1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 000000000000..3106dcbdb7f6 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +

+ Happy Hare +

Happy Hare

+

+ +

+Universal Automated Filament Changer / MMU driver for Klipper +

+ +

+ + +   + +   + +   + +   + +
+ +   + +

+ +Happy Hare is the original open-source filament changer controller for multi-color printing. Its philosophy is to provide a universal control system that adapts to your choice of MMU (Multi-Material Unit). If you switch MMUs, the software transitions seamlessly with you. Currently, it fully supports **ERCF**, **Tradrack**, **Box Turtle**, **Angry Beaver**, **Night Owl**, **3MS**, **3D Chameleon**, **QuattroBox**, **PicoMMU**, **KMS**, **BTT ViViD**, **EMU** and various other custom designs. + +The system is implemented as a Klipper extension (primarily using Python modules) to control MMUs and AFCs. It also provides functionality that can be customized through Klipper macros. With extensive configuration options for personalization, it includes an installer to simplify the initial setup for popular MMU and MCU types. For details about the different conceptual types of MMUs and the functions of their various sensors, refer to the [conceptual MMU guide](https://github.com/moggieuk/Happy-Hare/wiki/Conceptual-MMU). This guide is particularly useful for customized setups. For the best experience, pair it with the [KlipperScreen for Happy Hare](https://github.com/moggieuk/KlipperScreen-Happy-Hare-Edition) project together with a fully integrated Mainsail and Fluidd user experience. Extensive documentation is available in the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki). + + + +Happy Hare is under active development, with a meticulous focus on the quality of multi-color printing. It has benefited from insights gained over two years from thousands of users. While the experience is highly polished, development continues in three key areas: + +- **Additional MMU Support:** _Striving for inclusivity—support for Prusa MMU and others is in progress_ +- **v4 Rework:** _This release will allow for even more modularity and in particular support for dissimilar MMU/AFC's on the same printer! Yes, mix your old ERCF and BoxTurtle on the same machine or even direct to different toolheads in a IDEX design!_ + +Some users have inquired about making donations to support this project (and to keep my coffee or G&T supply steady!). While this project is a labor of love and not financially motivated, it is a substantial undertaking—comprising 18,000 lines of Python code, 10,000 lines of documentation, 160 illustrations and 6,000 lines of macros/configuration. If you’ve found value in Happy Hare and wish to contribute, donations can be made via PayPal https://www.paypal.me/moggieuk. Any support will be spent improving your experience with your favorite MMU/AFC. Thank you! +

+ +
+ +**Don't forget to join the dedicated Happy Hare community forum here: https://discord.gg/aABQUjkZPk** + +
+ +## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) Just a few of the features: + +- Support almost any brand of MMU (including mods) or custom monsters: + - ERCF + - Tradrack + - Box Turtle + - Angry Beaver + - Night Owl + - 3MS + - 3D Chameleon + - Quattro Box + - PicoMMU + - MMX + - KSM + - BTT ViViD + - Custom... +- Klipperscreen and Mainsail/Fluidd UI +- Support for all type of sensor: pre-gate, post-gear, combiner gate sensors, extruder entry sensors, toolhead sensors +- Full Spoolman integration +- Multiple MMUs managed as one (limited to type-A until v4 release) +- Support for motorized eSpooler filament buffer systems for rewinding +- Suite of startup macros that include sophisticated parking options for filament change or error operations +- Implements a Tool-to-Gate mapping so that the physical spool can be mapped to any tool +- EndlessSpool allowing a spool to automatically be mapped and take over from a spool that runs out +- Sophisticated logging options (console and separate mmu.log file) +- Can define material type and color in each gate for visualization and customized settings (like Pressure Advance) +- Automated calibration and tuning for easy setup +- Supports MMU "bypass" gate functionality +- Moonraker update-manager support +- Moonraker gcode pre-parsing to extract important print information +- Complete persistence of state and statistics across restarts +- Optional integrated encoder driver that validates filament movement, runout, clog detection and flow rate verification! +- Vast customization options most of which can be changed and tested at runtime +- Integrated help, testing and soak-testing procedures +- Gcode pre-processor check that all the required tools are available! +- Drives LEDs for functional feed and some bling! +- Built in tip forming and filament cutter support (both toolhead and at MMU) +- Synchronized movement of extruder and gear motors (with sync feedback control) to overcome friction and even work with FLEX materials! +- Lots more... Detail change log can be found in the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Change-Log) + +Controlling my oldest ERCF MMU with companion [customized KlipperScreen](https://github.com/moggieuk/Happy-Hare/wiki/Basic-Operation#---klipperscreen-happy-hare) for easy touchscreen MMU control and new Mainsail/Fluidd integration! + +

universal_mmu_driver.png

+ +

KlipperScreen-Happy Hare editionMailsail/Fluidd support

+ +
+ +## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) Installation + +Ok, ready to get started? The module can be installed into an existing Klipper setup with the supplied install script. Once installed it will be added to Moonraker update-manager to easy updates like other Klipper plugins. Full installation documentation is in the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Home) but start with cloning the repo onto your rpi: + +``` +cd ~ +git clone https://github.com/moggieuk/Happy-Hare.git +``` + +
+ +## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) Documentation + + + + + +
wiki +MMU's are complexd! Fortunately Happy Hare has elaborate documentation logically organized in the Wiki + +


+ +**Other Resources:** +

+ +Great (english) overview including Mainsail UI support + +
+Instructional video (german) created by Crydteam + +
+Happy Hare introduction (introduction) by Silverback +
+ +
+ +
+ +## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) Just how good a MMU multi-color prints? + +Although the journey to calibrating and setup can be a frustrating one, I wanted to share @igiannakas (ERCFv2 + Orca Slicer + Happy Hare) example prints here. Click on the image to zoom it. Incredible! :cool: :clap: + +

Example Prints

+

Example Prints

+
+ +## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) My Testing and Setup: + +Most of the development of Happy Hare was done on my trusty old ERCF v1.1 setup but as it's grown, so has my collection of MMU's and MCU controllers. Multi-color printing is addictive but can be frustrating during setup and learning. Be patient and use the forums for help! **But first read the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Home)!** + +

My Setup

+

+There once was a printer so keen,
+To print in red, yellow, and green.
+It whirred and it spun,
+Mixing colors for fun,
+The most vibrant prints ever seen!
+

+ +--- + +```yml + (\_/) + ( *,*) + (")_(") Happy Hare Ready +``` diff --git a/components/mmu_server.py b/components/mmu_server.py new file mode 100644 index 000000000000..a5c8912db6ab --- /dev/null +++ b/components/mmu_server.py @@ -0,0 +1,1282 @@ +# Happy Hare MMU Software +# Moonraker support for a file-preprocessor that injects MMU metadata into gcode files +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Original slicer parsing +# Copyright (C) 2023 Kieran Eglin <@kierantheman (discord)>, +# +# (\_/) +# ( *,*) +# (")_(") MMU Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +from __future__ import annotations +import json +import logging, os, sys, re, time, asyncio +import runpy, argparse, shutil, traceback, tempfile, filecmp +from typing import ( + TYPE_CHECKING, + List, + Dict, + Any, + Optional, + Union, + cast +) + +if TYPE_CHECKING: + from .spoolman import SpoolManager, DB_NAMESPACE, ACTIVE_SPOOL_KEY + from ..common import WebRequest + from ..common import RequestType + from ..confighelper import ConfigHelper + from .http_client import HttpClient, HttpResponse + from .database import MoonrakerDatabase + from .announcements import Announcements + from .klippy_apis import KlippyAPI as APIComp + from .history import History + from tornado.websocket import WebSocketClientConnection + +MMU_NAME_FIELD = 'printer_name' +MMU_GATE_FIELD = 'mmu_gate_map' +MIN_SM_VER = (0, 18, 1) + +DB_NAMESPACE = "moonraker" +ACTIVE_SPOOL_KEY = "spoolman.spool_id" + +class MmuServer: + def __init__(self, config: ConfigHelper): + self.config = config + self.server = config.get_server() + self.printer_info = self.server.get_host_info() + self.spoolman = None + if config.has_section("spoolman"): # Avoid exception if spoolman not configured + self.spoolman: SpoolManager = self.server.load_component(config, "spoolman", None) + self.spoolman: SpoolManager = self.server.lookup_component("spoolman", None) + self.klippy_apis: APIComp = self.server.lookup_component("klippy_apis") + self.http_client: HttpClient = self.server.lookup_component("http_client") + self.database: MoonrakerDatabase = self.server.lookup_component("database") + + # Full cache of spool_ids and location + key attributes (printer, gate, attr_dict)) + # Example: {2: ('BigRed', 0, {"material": "pla", "color": "ff56e0"}), 3: ('BigRed', 3, {"material": "abs"}), ... + self.spool_location = {} + + self.nb_gates = None # Set during initialization to the size of the MMU or 1 if standalone + self.cache_lock = asyncio.Lock() # Lock to serialize a async calls for Happy Hare + + # Spoolman filament info retrieval functionality and update reporting + if self.spoolman: + self.server.register_remote_method("spoolman_refresh", self.refresh_cache) + self.server.register_remote_method("spoolman_get_filaments", self.get_filaments) # "get" mode + self.server.register_remote_method("spoolman_push_gate_map", self.push_gate_map) # "push" mode + self.server.register_remote_method("spoolman_pull_gate_map", self.pull_gate_map) # "pull" mode + self.server.register_remote_method("spoolman_clear_spools_for_printer", self.clear_spools_for_printer) + self.server.register_remote_method("spoolman_set_spool_gate", self.set_spool_gate) + self.server.register_remote_method("spoolman_unset_spool_gate", self.unset_spool_gate) + self.server.register_remote_method("spoolman_get_spool_info", self.display_spool_info) + self.server.register_remote_method("spoolman_display_spool_location", self.display_spool_location) + + # Moonraker lane data push for slicer integration + self.server.register_remote_method("moonraker_push_lane_data", self.push_lane_data) + self.server.register_remote_method("moonraker_cleanup_lane_data", self.cleanup_lane_data) + + # Replace file_manager/metadata with this file + self.setup_placeholder_processor(config) + + # Options + self.update_location = self.config.getboolean("update_spoolman_location", True) + + async def _get_spoolman_version(self) -> tuple[int, int, int] | None: + response = await self.http_client.get(url=f'{self.spoolman.spoolman_url}/v1/info') + if response.status_code == 404: + logging.info(f"'{self.spoolman.spoolman_url}/v1/info' not found") + return False + elif response.has_error(): + err_msg = self.spoolman._get_response_error(response) + logging.error(f"Attempt to get info from spoolman failed: {err_msg}") + return False + else: + logging.info("info field in spoolman retrieved") + return tuple([int(n) for n in response.json()['version'].split('.')]) + + async def component_init(self) -> None: + if self.spoolman is None: + logging.warning("Spoolman not available. Happy Hare remote methods not available") + return + + # Get current printer hostname + self.printer_hostname = self.printer_info["hostname"] + self.spoolman_has_extras = False + asyncio.create_task(self._init_spoolman(retry=3)) # Spoolman may start up after us so retry a few times + + async def _init_spoolman(self, retry=1) -> bool: + ''' + Return True if connected, False if not. Set's self.spoolman_has_extras is + ''' + async with self.cache_lock: + for _ in range(retry): + self.spoolman_version = await self._get_spoolman_version() + if self.spoolman_version: + logging.info("Contacted Spoolman") + break + logging.warning(f"Spoolman not available. {'Retrying in 2 seconds...' if retry > 1 else ''}") + await asyncio.sleep(2) + + extras = False + if self.spoolman_version and self.spoolman_version >= MIN_SM_VER: + # Make sure db has required extra fields + extras = True + fields = await self._get_extra_fields("spool") + if MMU_NAME_FIELD not in fields: + extras = extras and await self._add_extra_field("spool", field_name="Printer Name", field_key=MMU_NAME_FIELD, field_type="text", default_value="") + if MMU_GATE_FIELD not in fields: + extras = extras and await self._add_extra_field("spool", field_name="MMU Gate", field_key=MMU_GATE_FIELD, field_type="integer", default_value=-1) + + # Create cache of spool location from Spoolman db for effeciency + if extras: + await self._build_spool_location_cache(silent=True) + self.spoolman_has_extras = extras + + elif self.spoolman_version: + logging.error(f"Could not initialize Spoolman db for Happy Hare. Spoolman db version too old (found {self.spoolman_version} < {MIN_SM_VER})") + else: + logging.error("Could not connect to Spoolman db. Perhaps it is not initialized yet? Will try again on next request") + return False + return True + + async def _check_init_spoolman(self, silent=False) -> bool: + if not self.spoolman_has_extras: + db_awake = await self._init_spoolman() + if not silent: + if not db_awake: + await self._log_n_send("Couldn't connect to Spoolman. Maybe not configured/running yet (check moonraker.log).\nUse MMU_SPOOLMAN REFRESH=1 to force retry") + elif not self.spoolman_has_extras: + await self._log_n_send("Incompatible Spoolman version for this feature. Check moonraker.log") + return self.spoolman_has_extras + + # !TODO: implement mainsail/fluidd gui prompts? + async def _log_n_send(self, msg, error=False, prompt=False, silent=False): + ''' + logs and sends msg to the klipper console + ''' + if error: + logging.error(msg) + else: + logging.info(msg) + if not silent: + if self._mmu_backend_enabled(): + error_flag = "ERROR=1" if error else "" + msg = msg.replace("\n", "\\n") # Get through klipper filtering + await self.klippy_apis.run_gcode(f"MMU_LOG MSG='{msg}' {error_flag}") + else: + for msg in msg.split("\n"): + await self.klippy_apis.run_gcode(f"M118 {msg}") + if error : + await self.klippy_apis.pause_print() + + async def _init_mmu_backend(self): + ''' + Initialize MMU backend and check if enabled + + returns: + @return: True if initialized, False otherwise + ''' + self.mmu_backend_present = 'mmu' in await self.klippy_apis.get_object_list() + if self.mmu_backend_present: + self.mmu_backend_config = await self.klippy_apis.query_objects({"mmu": None}) + self.mmu_enabled = self.mmu_backend_config.get('mmu', {}).get('enabled', False) + else: + self.mmu_enabled = False + logging.info(f"MMU backend present: {self.mmu_backend_present}") + logging.info(f"MMU backend enabled: {self.mmu_enabled}") + return True + + def _mmu_backend_enabled(self): + if not hasattr(self, 'mmu_backend_present'): + return False + return self.mmu_backend_present and self.mmu_enabled + + async def _initialize_mmu(self): + ''' + Initialize mmu gate map if not already done + + returns: + @return: True once initialized + ''' + if not hasattr(self, 'mmu_backend_present'): + await self._init_mmu_backend() + if self._mmu_backend_enabled(): + if self.config.has_option("num_gates"): + logging.warning("The 'num_gates' option in the moonraker [mmu_server] section is ignored when an MMU backend is present and enabled.") + self.nb_gates = self.mmu_backend_config.get('mmu', {}).get('num_gates', 0) + else: + self.nb_gates = self.config.getint("num_gates", 1) # for standalone usage (no mmu backend considering standard or (custom defined) printer setup) + logging.info(f"MMU num_gates: {self.nb_gates}") + return True + + async def _get_extra_fields(self, entity_type) -> bool: + ''' + Helper to gets all extra fields for the entity type + ''' + response = await self.http_client.get(url=f'{self.spoolman.spoolman_url}/v1/field/{entity_type}') + if response.status_code == 404: + logging.info(f"'{self.spoolman.spoolman_url}/v1/field/{entity_type}' not found") + return False + elif response.has_error(): + err_msg = self.spoolman._get_response_error(response) + logging.error(f"Attempt to get extra fields failed: {err_msg}") + return False + else: + logging.info(f"Extra fields for {entity_type} found") + return [r['key'] for r in response.json()] + + async def _add_extra_field(self, entity_type, field_key, field_name, field_type, default_value) -> bool: + ''' + Helper to add a new field to the extra field of the Spoolman db + ''' + #default_value = json.dumps(default_value) if field_type == 'text' else default_value + response = await self.http_client.post( + url=f'{self.spoolman.spoolman_url}/v1/field/{entity_type}/{field_key}', + body={"name" : field_name, "field_type" : field_type, "default_value" : json.dumps(default_value)} + ) + if response.status_code == 404: + logging.info(f"'{self.spoolman.spoolman_url}/v1/field/spool/{field_key}' not found") + return False + elif response.has_error(): + err_msg = self.spoolman._get_response_error(response) + logging.error(f"Attempt add field {field_name} failed: {err_msg}") + return False + logging.info(f"Field {field_name} added to Spoolman db for entity type {entity_type}") + logging.info(" -fields: %s", response.json()) + return True + + async def _fetch_spool_info(self, spool_id) -> dict | None: + ''' + Retrieve an individual spool_info record + ''' + response = await self.spoolman.http_client.request( + method="GET", + url=f'{self.spoolman.spoolman_url}/v1/spool/{spool_id}', + body=None) + if response.status_code == 404: + logging.error(f"'{self.spoolman.spoolman_url}/v1/spool/{spool_id}' not found") + return None + elif response.has_error(): + err_msg = self.spoolman._get_response_error(response) + logging.info(f"Attempt to fetch spool info failed: {err_msg}") + return None + spool_info = response.json() + return spool_info + + def _get_filament_attr(self, spool_info) -> dict: + spool_id = spool_info["id"] + filament = spool_info["filament"] + name = filament.get('name', '') + material = filament.get('material', '') + color_hex = filament.get('color_hex', '').strip('#')[:8].lower() # Remove problematic First # character if present + temp = filament.get('settings_extruder_temp', '') + bed_temp = filament.get('settings_bed_temp', '') + vendor = filament.get('vendor', {}).get('name', '') + filament_id = filament.get('id', '') + return {'spool_id': spool_id, 'material': material, 'color': color_hex, 'name': name, 'temp': temp, 'bed_temp': bed_temp, 'vendor': vendor, 'filament_id': filament_id} + + async def _build_spool_location_cache(self, fix=False, silent=False) -> bool: + ''' + Helper to get all spools and gates assigned to printers from Spoolman db and cache them + ''' + logging.info("Building spool location cache from Spoolman db") + try: + self.spool_location.clear() + # Fetch all spools + errors = "" + assignments = {} + sids_to_fix = [] + reponse = await self.http_client.get(url=f'{self.spoolman.spoolman_url}/v1/spool') + for spool_info in reponse.json(): + spool_id = spool_info['id'] + printer_name = json.loads(spool_info['extra'].get(MMU_NAME_FIELD, "\"\"")).strip('"') + mmu_gate = int(spool_info['extra'].get(MMU_GATE_FIELD, -1)) + filament_attr = self._get_filament_attr(spool_info) + self.spool_location[spool_id] = (printer_name, mmu_gate, filament_attr) + + if printer_name and mmu_gate >= 0: + if printer_name not in assignments: + assignments[printer_name] = {} + if mmu_gate not in assignments[printer_name]: + assignments[printer_name][mmu_gate] = [] + assignments[printer_name][mmu_gate].append(spool_id) + + # Highlight errors + if printer_name and mmu_gate < 0: + errors += f"\n - Spool {spool_id} has printer {printer_name} but no mmu_gate assigned" + sids_to_fix.append(spool_id) + if mmu_gate >= 0 and not printer_name: + errors += f"\n - Spool {spool_id} has mmu_gate {mmu_gate} but no printer assigned" + sids_to_fix.append(spool_id) + + for p, gates in assignments.items(): + for g, spool_list in gates.items(): + if len(spool_list) > 1: + errors += f"\n - Printer {p} @ gate {g} has multiple spool ids: {spool_list}" + sids_to_fix.extend(spool_list[1:]) + except Exception as e: + await self._log_n_send(f"Failed to retrieve spools from spoolman: {str(e)}", error=True, silent=silent) + return False + + if errors: + if fix: + errors += "\nWill attempt to fix..." + await self._log_n_send(f"Warning - Inconsistencies found in Spoolman db:{errors}", silent=silent) + + if fix: + tasks = {sid: self._unset_spool_gate(sid, silent=silent) for sid in sids_to_fix} + results = await asyncio.gather(*tasks.values()) + + # Log results and update cache + for sid, result in zip(tasks.keys(), results): + if result: + old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) + self.spool_location[sid] = ('', -1, filament_attr) + await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate}", silent=silent) + return True + + # Function to find the first spool_id with a matching 'printer/gate', just 'gate' or just 'printer' + def _find_first_spool_id(self, target_printer, target_gate): + return next((spoolid + for spoolid, (printer, gate, _) in self.spool_location.items() + if (target_printer is None or printer == target_printer) and gate == target_gate + ), -1) + + # Function to find all the spool_ids with a matching 'printer/gate', just 'gate' or just 'printer' + def _find_all_spool_ids(self, target_printer, target_gate): + return [ + spoolid + for spoolid, (printer, gate, _) in self.spool_location.items() + if (target_printer is None or printer == target_printer) and (target_gate is None or gate == target_gate) + ] + + async def _set_spool_gate(self, spool_id, printer, gate, silent=False) -> bool: + if not await self._check_init_spoolman(): return + + # Use the PATCH method on the spoolman api + if not silent: + logging.info(f"Setting spool {spool_id} for printer {printer} @ gate {gate}") + data = {'extra': {MMU_NAME_FIELD: json.dumps(f"{printer}"), MMU_GATE_FIELD: json.dumps(gate)}} + if self.update_location: + data['location'] = f"{printer} @ MMU Gate:{gate}" + response = await self.http_client.request( + method="PATCH", + url=f"{self.spoolman.spoolman_url}/v1/spool/{spool_id}", + body=json.dumps(data) + ) + if response.status_code == 404: + logging.error(f"'{self.spoolman.spoolman_url}/v1/spool/{spool_id}' not found") + await self._log_n_send(f"SpoolId {spool_id} not found", error=True, silent=False) + return False + elif response.has_error(): + err_msg = self.spoolman._get_response_error(response) + logging.error(f"Attempt to set spool failed: {err_msg}") + await self._log_n_send(f"Failed to set spool {spool_id} for printer {printer}. Look at moonraker.log for more details.", error=True, silent=False) + return False + return True + + async def _unset_spool_gate(self, spool_id, silent=False) -> bool: + if not await self._check_init_spoolman(): return + + # Use the PATCH method on the spoolman api + if not silent: + logging.info(f"Unsetting gate map on spool id {spool_id}") + data = {'extra': {MMU_NAME_FIELD: json.dumps(""), MMU_GATE_FIELD: json.dumps(-1)}} + if self.update_location: + data['location'] = "" + response = await self.http_client.request( + method="PATCH", + url=f"{self.spoolman.spoolman_url}/v1/spool/{spool_id}", + body=json.dumps(data) + ) + if response.status_code == 404: + logging.error(f"'{self.spoolman.spoolman_url}/v1/spool/{spool_id}' not found") + await self._log_n_send(f"SpoolId {spool_id} not found", error=True, silent=False) + return False + elif response.has_error(): + err_msg = self.spoolman._get_response_error(response) + logging.error(f"Attempt to unset spool failed: {err_msg}") + await self._log_n_send(f"Failed to unset spool {spool_id}. Look at moonraker.log for more details", error=True, silent=False) + return False + return True + + async def _send_gate_map_update(self, gate_ids, replace=False, silent=False) -> bool: + ''' + Retrieve filament attributes for list of (gate, spool_id) tuples + Pass back to Happy Hare. + + If no mmu backend has been detected, ignore the request + ''' + if self._mmu_backend_enabled(): + gate_dict = { + gate: ( + {'spool_id': -1} if spool_id < 0 else + self.spool_location.get(spool_id)[2].copy() + if self.spool_location.get(spool_id) + else logging.error(f"Spool id {spool_id} requested but not found in spoolman") + ) + for gate, spool_id in gate_ids + } + try: + await self.klippy_apis.run_gcode(f"MMU_GATE_MAP MAP=\"{gate_dict}\" {'REPLACE=1' if replace else ''} FROM_SPOOLMAN=1 QUIET=1") + except Exception as e: + await self._log_n_send(f"Exception running MMU_GATE_MAP gcode: {str(e)}", error=True, silent=silent) + return False + return True + + async def refresh_cache(self, fix=False, silent=False) -> bool: + ''' + Rebuilds the local cache of essential spool information + ''' + if not await self._check_init_spoolman(): return + async with self.cache_lock: + await self._initialize_mmu() + return await self._build_spool_location_cache(fix=fix, silent=silent) + + async def get_filaments(self, gate_ids, silent=False) -> bool: + ''' + Retrieve filament attributes for list of (gate, spool_id) tuples + Pass back to Happy Hare. Does not require extended Spoolman db + ''' + async with self.cache_lock: + return await self._send_gate_map_update(gate_ids, silent=silent) + + async def push_gate_map(self, gate_ids=None, silent=False) -> bool: + ''' + Store the gate map for the printer for a list of (gate, spool_id) tuples. + This attempts to reduce the number of necessary tasks and then run them in parallel + Then updates Happy Hare with filament attributes + ''' + if not await self._check_init_spoolman(): return + async with self.cache_lock: + await self._initialize_mmu() + + if not gate_ids: + logging.error("Gate spool id mapping not provided or empty") + return False + + # Make sure we cleanup all the gate's old spool_id association + updates = {} + for gate, spool_id in gate_ids: + old_sids = self._find_all_spool_ids(self.printer_hostname, gate) + for old_sid in old_sids: + updates[old_sid] = -1 + + # Now layer in the supplied gate map + for gate, spool_id in gate_ids: + if spool_id > 0: + updates[spool_id] = gate + + # If setting a full gate map, include updates for "dirty" spool id's + # that are not otherwise going to be overwritten + if len(gate_ids) == self.nb_gates: + for spool_id, (p_name, gate, _) in self.spool_location.items(): + if p_name == self.printer_hostname and not any(s == spool_id for _, s in gate_ids): + updates[spool_id] = -1 + + # Create minimal set of async tasks to update Spoolman db and run them in parallel + tasks = { + sid: ( + self._unset_spool_gate(sid, silent=silent), + None + ) if updates[sid] < 0 else ( + self._set_spool_gate(sid, self.printer_hostname, updates[sid], silent=silent), + updates[sid] + ) + for sid in updates.keys() + } + results = await asyncio.gather(*[task for task,_ in tasks.values()]) + + # Log results and update cache + for sid, result in zip(tasks.keys(), results): + if result: + old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) + gate = tasks[sid][1] + if updates[sid] < 0: # 'unset' case + self.spool_location[sid] = ('', -1, filament_attr) + self.server.send_event("spoolman:unset_spool_gate", {"spool_id": sid, "printer": old_printer, "gate": old_gate}) + await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate} in Spoolman db", silent=silent) + else: # 'set' case + self.spool_location[sid] = (self.printer_hostname, gate, filament_attr) + self.server.send_event("spoolman:set_spool_gate", {"spool_id": sid, "printer": self.printer_hostname, "gate": gate}) + await self._log_n_send(f"Spool {sid} assigned to printer {self.printer_hostname} @ gate {gate} in Spoolman db", silent=silent) + + # Send update of filament attributes back to Happy Hare + return await self._send_gate_map_update(gate_ids, silent=silent) + + async def pull_gate_map(self, silent=False) -> bool: + ''' + Get all spools assigned to the current printer from Spoolman db and map them to gates + Pass back to Happy Hare + ''' + if not await self._check_init_spoolman(): return + async with self.cache_lock: + await self._initialize_mmu() + + gate_ids = [(gate, self._find_first_spool_id(self.printer_hostname, gate)) for gate in range(self.nb_gates)] + return await self._send_gate_map_update(gate_ids, replace=True, silent=silent) + + async def clear_spools_for_printer(self, printer=None, sync=False, silent=False) -> bool: + ''' + Clears all gates for the printer + ''' + if not await self._check_init_spoolman(): return + async with self.cache_lock: + await self._initialize_mmu() + + printer_name = printer or self.printer_hostname + if not silent: + logging.info(f"Clearing gate map for printer: {printer_name}") + + # Create minimal set of async tasks to update Spoolman db and run them in parallel + old_sids = self._find_all_spool_ids(printer_name, None) + tasks = {sid: self._unset_spool_gate(sid, silent=silent) for sid in old_sids} + results = await asyncio.gather(*tasks.values()) + + # Log results and update cache + updated_gate_ids = {} + for sid, result in zip(tasks.keys(), results): + if result: + old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) + if old_printer == self.printer_hostname and 0 <= old_gate < self.nb_gates and not updated_gate_ids.get(old_gate): + updated_gate_ids[old_gate] = -1 + self.spool_location[sid] = ('', -1, filament_attr) + self.server.send_event("spoolman:unset_spool_gate", {"spool_id": sid, "printer": old_printer, "gate": old_gate}) + await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate}", silent=silent) + + self.server.send_event("spoolman:clear_spool_gates", {"printer": printer_name}) + if sync and updated_gate_ids: + gate_ids = [(gate, spool_id) for gate, spool_id in updated_gate_ids.items()] + return await self._send_gate_map_update(gate_ids, replace=True, silent=silent) + return True + + async def set_spool_gate(self, spool_id=None, gate=None, sync=False, silent=False) -> bool: + ''' + Associate spool_id with the printer and gate and clear up any old associations + + parameters: + @param spool_id: id of the spool to set + @param gate: optional gate number to set the spool into. If not provided (and not an mmu), the spool will be set into gate 0. + returns: + @return: True if successful, False otherwise + Removes the printer + gate allocation in Spoolman db for gate (if supplied) + ''' + if not await self._check_init_spoolman(): return + async with self.cache_lock: + await self._initialize_mmu() + + # Sanity checking... + if gate is not None and gate < 0: + await self._log_n_send("Trying to set spool {spool_id} for printer {self.printer_hostname} but gate {gate} is invalid.", error=True, silent=silent) + return False + if gate is not None and gate > self.nb_gates - 1: + await self._log_n_send(f"Trying to set spool {spool_id} for printer {self.printer_hostname} @ gate {gate} but only {self.nb_gates} gates are available. Please check the spoolman or moonraker [spoolman] setup.", error=True, silent=silent) + return False + if gate is None: + if self.nb_gates: + await self._log_n_send(f"Trying to set spool {spool_id} for printer {self.printer_hostname} but printer has an MMU with {self.nb_gates} gates. Please check the spoolman or moonraker [spoolman] setup.", error=True, silent=silent) + return False + gate = 0 + + if not silent: + logging.info(f"Attempting to set gate {gate} for printer {self.printer_hostname}") + + # Create minimal set of async tasks to update Spoolman db and run them in parallel + old_sids = self._find_all_spool_ids(self.printer_hostname, gate) + tasks = { + sid: (self._unset_spool_gate(sid, silent=silent), None) + for sid in old_sids if sid != spool_id + } + tasks[spool_id] = (self._set_spool_gate(spool_id, self.printer_hostname, gate, silent=silent), gate) + results = await asyncio.gather(*[task for task,_ in tasks.values()]) + + # Log results and update cache + updated_gate_ids = {} + for sid, result in zip(tasks.keys(), results): + if result: + old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) + gate = tasks[sid][1] + if sid in old_sids and sid != spool_id: + # 'unset' case + if old_printer == self.printer_hostname and 0 <= old_gate < self.nb_gates and not updated_gate_ids.get(old_gate): + updated_gate_ids[old_gate] = -1 + self.spool_location[sid] = ('', -1, filament_attr) + self.server.send_event("spoolman:unset_spool_gate", {"spool_id": sid, "printer": old_printer, "gate": old_gate}) + await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate} in Spoolman db", silent=silent) + else: + # 'set' case + if 0 <= gate < self.nb_gates: + if old_printer == self.printer_hostname and 0 <= old_gate < self.nb_gates and not updated_gate_ids.get(old_gate): + updated_gate_ids[old_gate] = -1 + updated_gate_ids[gate] = sid + self.spool_location[sid] = (self.printer_hostname, gate, filament_attr) + self.server.send_event("spoolman:set_spool_gate", {"spool_id": sid, "printer": self.printer_hostname, "gate": gate}) + await self._log_n_send(f"Spool {sid} assigned to printer {self.printer_hostname} @ gate {gate} in Spoolman db", silent=silent) + + # Sync with Happy Hare if required + if sync and updated_gate_ids: + gate_ids = [(gate, spool_id) for gate, spool_id in updated_gate_ids.items()] + return await self._send_gate_map_update(gate_ids, replace=True, silent=silent) + return True + + async def unset_spool_gate(self, spool_id=None, gate=None, sync=False, silent=False) -> bool: + ''' + Removes the printer + gate allocation in Spoolman db for gate or spool_id (if supplied) + ''' + if not await self._check_init_spoolman(): return + async with self.cache_lock: + await self._initialize_mmu() + + # Sanity checking... + if spool_id is None and gate is None: + await self._log_n_send("Trying to unset spool but no spool_id or gate provided", error=True, silent=silent) + return False + if spool_id is not None and gate is not None: + await self._log_n_send(f"Trying to unset spool but both spool_id {spool_id} and gate {gate} provided. Only one or the other expected", error=True, silent=silent) + return False + if spool_id is not None: + if not self.spool_location.get(spool_id, ('', -1, {})): + await self._log_n_send(f"Trying to unset spool {spool_id} but not found in cache. Perhaps try refreshing cache", error=True, silent=silent) + return False + + # Create minimal set of async tasks to update Spoolman db and run them in parallel + sids = self._find_all_spool_ids(self.printer_hostname, gate) if gate is not None else [spool_id] + tasks = {sid: self._unset_spool_gate(sid, silent=silent) for sid in sids} + results = await asyncio.gather(*tasks.values()) + + # Log results and update cache + updated_gate_ids = {} + for sid, result in zip(tasks.keys(), results): + if result: + old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) + if old_printer == self.printer_hostname and 0 <= old_gate < self.nb_gates and not updated_gate_ids.get(old_gate): + updated_gate_ids[old_gate] = -1 + self.spool_location[sid] = ('', -1, filament_attr) + self.server.send_event("spoolman:unset_spool_gate", {"spool_id": sid, "old_printer": self.printer_hostname, "old_gate": gate}) + await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate} in Spoolman db", silent=silent) + + # Sync with Happy Hare if required + if sync and updated_gate_ids: + gate_ids = [(gate, spool_id) for gate, spool_id in updated_gate_ids.items()] + return await self._send_gate_map_update(gate_ids, replace=True, silent=silent) + return True + + async def display_spool_info(self, spool_id: int | None = None): + ''' + Gets info for active spool id and sends it to the klipper console. Does not require Spoolman db extension + ''' + async with self.cache_lock: + active = "Spool" + + if not spool_id: + logging.info("Fetching active spool") + spool_id = await self.spoolman.database.get_item(DB_NAMESPACE, ACTIVE_SPOOL_KEY, None) + active = "Active spool" + + if not spool_id: + msg = "No active spool set and no spool id supplied" + await self._log_n_send(msg, error=True) + return False + + spool_info = await self._fetch_spool_info(spool_id) + if not spool_info: + msg = f"Spool id {spool_id} not found" + await self._log_n_send(msg, error=True) + return False + + material = spool_info.get('material', "n/a") + used_weight = int(spool_info.get('used_weight', -1)) + f_used_weight = f"{used_weight} g" if used_weight >= 0 else "n/a" + remaining_weight = int(spool_info.get('remaining_weight', -1)) + f_remaining_weight = f"{remaining_weight} g" if remaining_weight >= 0 else "n/a" + msg = f"{active} is: {spool_info['filament']['name']} (id: {spool_info['id']})\n" + msg += f" - Material: {material}\n" + msg += f" - Used: {f_used_weight}\n" + msg += f" - Remaining: {f_remaining_weight}\n" + + # Check if spool_id is assigned + spool = next((gate for sid, (printer, gate, _) in self.spool_location.items() if spool_id == sid and self.printer_hostname == printer), None) + if spool is not None: + msg += f" - Gate: {spool}" + else: + msg += f"Spool id {spool_id} is not assigned to this printer!\n" + msg += f"Run: MMU_SPOOLMAN SPOOLID={spool_id} GATE=.. to add" + await self._log_n_send(msg) + return True + + async def display_spool_location(self, printer=None): + ''' + Builds a sorted table of gate to spool association for the specified printer and sends to klipper console + ''' + if not await self._check_init_spoolman(): return + async with self.cache_lock: + await self._initialize_mmu() + printer_name = printer or self.printer_hostname + filtered = sorted(((spool_id, gate) for spool_id, (printer, gate, _) in self.spool_location.items() if printer == printer_name), key=lambda x: x[1]) + if filtered: + msg = f"Spoolman gate assignment for printer: {printer_name}\n" + msg += "Gate | SpoolId\n" + msg += "-----+--------\n" + if self.nb_gates: + for mmu_gate in range(self.nb_gates): + sids = [spool_id for (spool_id, gate) in filtered if gate == mmu_gate] + sids_str = ",".join(map(str, sids)) + warning = " Error: Can only have a single spool assigned" if len(sids) > 1 else "" + msg += f"{mmu_gate:<5}| {sids_str}{warning}\n" + else: + # If not initialize_mmu() we will get here + for spool_id, gate in filtered: + msg += f"{gate:<5}| {spool_id}\n" + msg += "Run: MMU_SPOOLMAN REFRESH=1 to reset number of MMU gates" + else: + msg = f"No gates assigned for printer: {printer_name}" + await self._log_n_send(msg) + + async def push_lane_data(self, gate_ids): + ''' + Pushes lane data to Moonraker database for slicer integration (OrcaSlicer) + gate_ids: list of tuples [(gate, spool_id), ...] + ''' + try: + from datetime import datetime, timezone + + # Get MMU state from Klipper + mmu_state = await self.klippy_apis.query_objects({"mmu": None}) + mmu = mmu_state.get('mmu', {}) + + gate_material = mmu.get('gate_material', []) + gate_color = mmu.get('gate_color', []) + gate_temperature = mmu.get('gate_temperature', []) + gate_status = mmu.get('gate_status', []) + gate_filament_name = mmu.get('gate_filament_name', []) + + # Build batch of lane data + batch_data = {} + + for gate, spool_id in gate_ids: + if gate < 0: + continue + + # Lane uses same 0-based numbering as gate + lane = gate + lane_key = f"lane{lane}" + + # Check if gate is empty (status -1/unknown or 0/empty, or spool_id -1) + gate_status_val = gate_status[gate] if gate < len(gate_status) else -1 + is_empty = gate_status_val in [-1, 0] or spool_id == -1 + + if is_empty: + # Empty gate format + lane_data = { + "vendor_name": None, + "name": None, + "color": None, + "material": None, + "bed_temp": None, + "nozzle_temp": None, + "scan_time": None, + "td": None, + "lane": str(lane), + "spool_id": None, + "filament_id": None + } + else: + spool_attrs = self.spool_location.get(spool_id, ('', -1, {}))[2] if spool_id in self.spool_location else {} + # Populated gate format + lane_data = { + "vendor_name": spool_attrs.get('vendor', None) or None, + "name": (gate_filament_name[gate] if gate < len(gate_filament_name) else None) or spool_attrs.get('name', None) or None, + "color": gate_color[gate] if gate < len(gate_color) else None, + "td": None, # we don't currently capture transmision distance and isn't standard in spoolman + "material": gate_material[gate] if gate < len(gate_material) else None, + "bed_temp": spool_attrs.get('bed_temp', None) or None, + "nozzle_temp": gate_temperature[gate] if gate < len(gate_temperature) else 200, + "scan_time": None, + "lane": str(lane), # currently orca reads this as a string, but it is actually an int representing the gate number + "spool_id": spool_id if spool_id > 0 else None, + "filament_id": spool_attrs.get('filament_id', None) or None + } + + batch_data[lane_key] = lane_data + + # Push all lane data in a single batch + if batch_data: + await self.database.insert_batch("lane_data", batch_data) + + except Exception as e: + logging.error(f"Error pushing lane data: {e}") + + async def cleanup_lane_data(self, num_gates): + ''' + Removes lane data for gates that no longer exist (e.g., if MMU size was reduced) + num_gates: current number of gates in the MMU + ''' + try: + # Get all items in the lane_data namespace + lane_items = await self.database.get_item("lane_data", None, {}) + + # Delete lanes beyond the current num_gates (0-based: valid lanes are 0 to num_gates-1) + keys_to_delete = [] + for lane_key, lane_value in lane_items.items(): + # Extract lane number from the data object's lane field + if isinstance(lane_value, dict): + lane_str = lane_value.get('lane', '') + try: + lane_num = int(lane_str) + # If lane number >= num_gates, it's out of bounds, mark for deletion + if lane_num >= num_gates: + keys_to_delete.append(lane_key) + except (ValueError, TypeError): + continue + + # Delete the old lanes + await self.database.delete_batch("lane_data", keys_to_delete) + logging.info(f"Removed old lane data: {keys_to_delete}") + + except Exception as e: + logging.error(f"Error cleaning up lane data: {e}") + + + + # Switch out the metadata processor with this module which handles placeholders + def setup_placeholder_processor(self, config): + args = " -m" if config.getboolean("enable_file_preprocessor", True) else "" + args += " -n" if config.getboolean("enable_toolchange_next_pos", True) else "" + from .file_manager import file_manager + file_manager.METADATA_SCRIPT = os.path.abspath(__file__) + args + +def load_component(config): + return MmuServer(config) + + + +################################################################################## +# +# Beyond this point this module acts like an extended file_manager/metadata module +# +AUTHORZIED_SLICERS = ['PrusaSlicer', 'SuperSlicer', 'OrcaSlicer', 'BambuStudio'] + +HAPPY_HARE_FINGERPRINT = "; processed by HappyHare" +MMU_REGEX = r"^" + HAPPY_HARE_FINGERPRINT +SLICER_REGEX = r"^;.*generated by ([a-z]*) .*$|^; (BambuStudio) .*$" +ORCASLICER_VERSION_REGEX = r"^;\s*generated by OrcaSlicer\s+(\d+(?:\.\d+){0,3})" + +TOOL_DISCOVERY_REGEX = r"((^MMU_CHANGE_TOOL(_STANDALONE)? .*?TOOL=)|(^T))(?P\d{1,2})" + +METADATA_TOOL_DISCOVERY = "!referenced_tools!" +METADATA_TOTAL_TOOLCHANGES = "!total_toolchanges!" + +METADATA_BEGIN_PURGING = "CP TOOLCHANGE WIPE" +METADATA_END_PURGING = "CP TOOLCHANGE END" + +# PS/SS uses "extruder_colour", Orca uses "filament_colour" but extruder_colour can exist with empty or single color +COLORS_REGEX = { + 'PrusaSlicer' : r"^;\s*(?:extruder|filament)_colour\s*=\s*(#.*;*.*)$", #if extruder colour is not set, check filament colour + 'SuperSlicer' : r"^;\s*extruder_colour\s*=\s*(#.*;*.*)$", + 'OrcaSlicer' : r"^;\s*filament_colour\s*=\s*(#.*;*.*)$", + 'BambuStudio' : r"^;\s*filament_colour\s*=\s*(#.*;*.*)$", +} +METADATA_COLORS = "!colors!" + +TEMPS_REGEX = r"^;\s*(nozzle_)?temperature\s*=\s*(.*)$" # Orca Slicer/Bambu Studio has the 'nozzle_' prefix, others might not +METADATA_TEMPS = "!temperatures!" + +MATERIALS_REGEX = r"^;\s*filament_type\s*=\s*(.*)$" +METADATA_MATERIALS = "!materials!" + +PURGE_VOLUMES_REGEX = r"^;\s*(flush_volumes_matrix|wiping_volumes_matrix)\s*=\s*(.*)$" # flush.. in Orca/Bambu, wiping... in PS +METADATA_PURGE_VOLUMES = "!purge_volumes!" + +FLUSH_MULTIPLIER_REGEX = r"^;\s*flush_multiplier\s*=\s*(.*)$" #flush multiplier in Orca/Bambu. Used to multiply the values in the purge volumes to match the slicer UI settings + +FILAMENT_NAMES_REGEX = r"^;\s*(filament_settings_id)\s*=\s*(.*)$" +METADATA_FILAMENT_NAMES = "!filament_names!" + +# Detection for next pos processing +T_PATTERN = r'^T(\d+)\s*(?:;.*)?$' +G1_PATTERN = r'^G[01](?=.*\sX(-?[\d.]+))(?=.*\sY(-?[\d.]+)).*$' + +def _parse_version_tuple(version_str: str, parts: int = 3): + """Parse a version like '2.3.2-dev'/'2.3.2' into a comparable tuple (2, 3, 2). + + Only the numeric dot-separated prefix is considered; missing parts are padded with zeros. + """ + if not version_str: + return None + m = re.match(r"^\s*(\d+(?:\.\d+)*)", version_str) + if not m: + return None + nums = m.group(1).split(".") + out = [] + for s in nums[:parts]: + try: + out.append(int(s)) + except ValueError: + out.append(0) + while len(out) < parts: + out.append(0) + return tuple(out) + +def _format_volume(v: float) -> str: + """Format a purge volume number without trailing .0, keeping up to 1 decimal place.""" + v = round(float(v), 1) + s = f"{v:.1f}" + return s.rstrip("0").rstrip(".") + +def gcode_processed_already(file_path): + """Expects first line of gcode to be the HAPPY_HARE_FINGERPRINT '; processed by HappyHare'""" + + mmu_regex = re.compile(MMU_REGEX, re.IGNORECASE) + + with open(file_path, 'r') as in_file: + line = in_file.readline() + return mmu_regex.match(line) + +def parse_gcode_file(file_path): + slicer_regex = re.compile(SLICER_REGEX, re.IGNORECASE) + orca_version_regex = re.compile(ORCASLICER_VERSION_REGEX, re.IGNORECASE) + has_tools_placeholder = has_total_toolchanges = has_colors_placeholder = has_temps_placeholder = has_materials_placeholder = has_purge_volumes_placeholder = filament_names_placeholder = False + found_colors = found_temps = found_materials = found_purge_volumes = found_filament_names = found_flush_multiplier = False + slicer = None + orca_version = None + + tools_used = set() + total_toolchanges = 0 + colors = [] + temps = [] + materials = [] + purge_volumes = [] + filament_names = [] + flush_multiplier = 1.0 # Initialize flush_multiplier to 1.0 + + with open(file_path, 'r') as in_file: + for line in in_file: + if not line.startswith(";"): + continue + + # Discover slicer + if not slicer: + match = slicer_regex.match(line) + if match: + slicer = match.group(1) or match.group(2) + + # Capture OrcaSlicer version (numeric prefix only, e.g. 2.3.2 from 2.3.2-dev) + if orca_version is None: + mver = orca_version_regex.match(line) + if mver: + orca_version = _parse_version_tuple(mver.group(1)) + if slicer in AUTHORZIED_SLICERS: + if isinstance(TOOL_DISCOVERY_REGEX, dict): + tools_regex = re.compile(TOOL_DISCOVERY_REGEX[slicer], re.IGNORECASE) + else: + tools_regex = re.compile(TOOL_DISCOVERY_REGEX, re.IGNORECASE) + if isinstance(COLORS_REGEX, dict): + colors_regex = re.compile(COLORS_REGEX[slicer], re.IGNORECASE) + else: + colors_regex = re.compile(COLORS_REGEX, re.IGNORECASE) + if isinstance(TEMPS_REGEX, dict): + temps_regex = re.compile(TEMPS_REGEX[slicer], re.IGNORECASE) + else: + temps_regex = re.compile(TEMPS_REGEX, re.IGNORECASE) + if isinstance(MATERIALS_REGEX, dict): + materials_regex = re.compile(MATERIALS_REGEX[slicer], re.IGNORECASE) + else: + materials_regex = re.compile(MATERIALS_REGEX, re.IGNORECASE) + if isinstance(PURGE_VOLUMES_REGEX, dict): + purge_volumes_regex = re.compile(PURGE_VOLUMES_REGEX[slicer], re.IGNORECASE) + else: + purge_volumes_regex = re.compile(PURGE_VOLUMES_REGEX, re.IGNORECASE) + if isinstance(FILAMENT_NAMES_REGEX, dict): + filament_names_regex = re.compile(FILAMENT_NAMES_REGEX[slicer], re.IGNORECASE) + else: + filament_names_regex = re.compile(FILAMENT_NAMES_REGEX, re.IGNORECASE) + + if isinstance(FLUSH_MULTIPLIER_REGEX, dict): + flush_multiplier_regex = re.compile(FLUSH_MULTIPLIER_REGEX[slicer], re.IGNORECASE) + else: + flush_multiplier_regex = re.compile(FLUSH_MULTIPLIER_REGEX, re.IGNORECASE) + + with open(file_path, 'r') as in_file: + for line in in_file: + # !referenced_tools! and !total_toolchanges! processing + if not has_tools_placeholder and METADATA_TOOL_DISCOVERY in line: + has_tools_placeholder = True + + if not has_total_toolchanges and METADATA_TOTAL_TOOLCHANGES in line: + has_total_toolchanges = True + + match = tools_regex.match(line) + if match: + tool = match.group("tool") + tools_used.add(int(tool)) + total_toolchanges += 1 + + # !colors! processing + if not has_colors_placeholder and METADATA_COLORS in line: + has_colors_placeholder = True + + if not found_colors: + match = colors_regex.match(line) + if match: + colors_csv = [color.strip().lstrip('#') for color in match.group(1).split(';')] + if not colors: + colors.extend(colors_csv) + else: + colors = [n if o == '' else o for o,n in zip(colors,colors_csv)] + found_colors = all(len(c) > 0 for c in colors) + + # !temperatures! processing + if not has_temps_placeholder and METADATA_TEMPS in line: + has_temps_placeholder = True + + if not found_temps: + match = temps_regex.match(line) + if match: + temps_csv = re.split(';|,', match.group(2).strip()) + temps.extend(temps_csv) + found_temps = True + + # !materials! processing + if not has_materials_placeholder and METADATA_MATERIALS in line: + has_materials_placeholder = True + + if not found_materials: + match = materials_regex.match(line) + if match: + materials_csv = match.group(1).strip().split(';') + materials.extend(materials_csv) + found_materials = True + + # flush_multiplier processing + if not found_flush_multiplier: + match = flush_multiplier_regex.match(line) + if match: + try: + flush_multiplier = float(match.group(1).strip()) + except ValueError: + flush_multiplier = 1.0 # Default to 1.0 if conversion fails + found_flush_multiplier = True + + # !purge_volumes! processing + if not has_purge_volumes_placeholder and METADATA_PURGE_VOLUMES in line: + has_purge_volumes_placeholder = True + + if not found_purge_volumes: + match = purge_volumes_regex.match(line) + if match: + purge_volumes_csv = [v.strip() for v in match.group(2).strip().split(',')] + + # OrcaSlicer 2.3.2+ already bakes flush_multiplier into the flush_volumes_matrix. + # OrcaSlicer <=2.3.1 requires applying flush_multiplier here to match the UI. + apply_flush_multiplier = True + if slicer == "OrcaSlicer" and orca_version is not None and orca_version >= (2, 3, 2): + apply_flush_multiplier = False + + for volume_str in purge_volumes_csv: + # If we shouldn't apply multiplier, keep the raw value as-is (preserves integer formatting). + if not apply_flush_multiplier or flush_multiplier == 1.0: + purge_volumes.append(volume_str) + continue + try: + volume = float(volume_str) + multiplied_volume = volume * flush_multiplier + purge_volumes.append(_format_volume(multiplied_volume)) + except ValueError: + # If conversion fails, keep the original value + purge_volumes.append(volume_str) + found_purge_volumes = True + + # !filament_names! processing + if not filament_names_placeholder and METADATA_FILAMENT_NAMES in line: + filament_names_placeholder = True + + if not found_filament_names: + match = filament_names_regex.match(line) + if match: + filament_names_csv = [e.strip() for e in re.split(',|;', match.group(2).strip())] + filament_names.extend(filament_names_csv) + found_filament_names = True + + return (has_tools_placeholder or has_total_toolchanges or has_colors_placeholder or has_temps_placeholder or has_materials_placeholder or has_purge_volumes_placeholder or filament_names_placeholder, + sorted(tools_used), total_toolchanges, colors, temps, materials, purge_volumes, filament_names, slicer) + +def process_file(input_filename, output_filename, insert_nextpos, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names): + + t_pattern = re.compile(T_PATTERN) + g1_pattern = re.compile(G1_PATTERN) + + with open(input_filename, 'r') as infile, open(output_filename, 'w') as outfile: + buffer = [] # Buffer lines between a "T" line and the next matching "G1" line + tool = None # Store the tool number from a "T" line + outfile.write(f'{HAPPY_HARE_FINGERPRINT}\n') + + for line in infile: + line = add_placeholder(line, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names) + if tool is not None: + # Buffer subsequent lines after a "T" line until next "G1" x,y move line is found + buffer.append(line) + g1_match = g1_pattern.match(line) + if g1_match: + # Now replace "T" line and write buffered lines, including the current "G1" line + if insert_nextpos: + x, y = g1_match.groups() + outfile.write(f'MMU_CHANGE_TOOL TOOL={tool} NEXT_POS="{x},{y}" ; T{tool}\n') + else: + outfile.write(f'MMU_CHANGE_TOOL TOOL={tool} ; T{tool}\n') + for buffered_line in buffer: + outfile.write(buffered_line) + buffer.clear() + tool = None + continue + + t_match = t_pattern.match(line) + if t_match: + tool = t_match.group(1) + else: + outfile.write(line) + + # If there is anything left in buffer it means there wasn't a final "G1" line + if buffer: + outfile.write(f"T{tool}\n") + outfile.write(f'MMU_CHANGE_TOOL TOOL={tool} ; T{tool}\n') + for line in buffer: + outfile.write(line) + + # Finally append "; referenced_tools =" as new metadata (why won't Prusa pick up my PR?) + outfile.write("; referenced_tools = %s\n" % ",".join(map(str, tools_used))) + +def add_placeholder(line, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names): + # Ignore comment lines to preserve slicer metadata comments + if not line.startswith(";"): + if METADATA_TOOL_DISCOVERY in line: + if tools_used: + line = line.replace(METADATA_TOOL_DISCOVERY, ",".join(map(str, tools_used))) + else: + line = line.replace(METADATA_TOOL_DISCOVERY, "0") + if METADATA_TOTAL_TOOLCHANGES in line: + line = line.replace(METADATA_TOTAL_TOOLCHANGES, str(total_toolchanges)) + if METADATA_COLORS in line: + line = line.replace(METADATA_COLORS, ",".join(map(str, colors))) + if METADATA_TEMPS in line: + line = line.replace(METADATA_TEMPS, ",".join(map(str, temps))) + if METADATA_MATERIALS in line: + line = line.replace(METADATA_MATERIALS, ",".join(map(str, materials))) + if METADATA_PURGE_VOLUMES in line: + line = line.replace(METADATA_PURGE_VOLUMES, ",".join(map(str, purge_volumes))) + if METADATA_FILAMENT_NAMES in line: + line = line.replace(METADATA_FILAMENT_NAMES, ",".join(map(str, filament_names))) + else: + if METADATA_BEGIN_PURGING in line: + line = line + "_MMU_STEP_SET_ACTION STATE=12\n" + elif METADATA_END_PURGING in line: + line = line + "_MMU_STEP_SET_ACTION RESTORE=1\n" + return line + +def main(path, filename, insert_placeholders=False, insert_nextpos=False): + file_path = os.path.join(path, filename) + if not os.path.isfile(file_path): + metadata.logger.info(f"File Not Found: {file_path}") + sys.exit(-1) + try: + metadata.logger.info(f"mmu_server: Pre-processing file: {file_path}") + fname = os.path.basename(file_path) + if fname.endswith(".gcode") and not gcode_processed_already(file_path): + with tempfile.TemporaryDirectory() as tmp_dir_name: + tmp_file = os.path.join(tmp_dir_name, fname) + + if insert_placeholders: + start = time.time() + has_placeholder, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names, slicer = parse_gcode_file(file_path) + metadata.logger.info("Reading placeholders took %.2fs. Detected gcode by slicer: %s" % (time.time() - start, slicer)) + else: + tools_used = total_toolchanges = colors = temps = materials = purge_volumes = filament_names = slicer = None + has_placeholder = False + + if (insert_nextpos and tools_used is not None and len(tools_used) > 0) or has_placeholder: + start = time.time() + msg = [] + if has_placeholder: + msg.append("Writing MMU placeholders") + if insert_nextpos: + msg.append("Inserting next position to tool changes") + process_file(file_path, tmp_file, insert_nextpos, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names) + metadata.logger.info("mmu_server: %s took %.2fs" % (",".join(msg), time.time() - start)) + + # Move temporary file back in place + if os.path.islink(file_path): + file_path = os.path.realpath(file_path) + if not filecmp.cmp(tmp_file, file_path): + shutil.move(tmp_file, file_path) + else: + metadata.logger.info(f"Files are the same, skipping replacement of: {file_path} by {tmp_file}") + else: + metadata.logger.info(f"No MMU metadata placeholders found in file: {file_path}") + + except Exception: + metadata.logger.info(traceback.format_exc()) + sys.exit(-1) + +# When run separately this module wraps metadata to extend pre-processing functionality +if __name__ == "__main__": + # Make it look like we are running in the file_manager directory + directory = os.path.dirname(os.path.abspath(__file__)) + target_dir = directory + "/file_manager" + os.chdir(target_dir) + sys.path.insert(0, target_dir) + + import metadata + metadata.logger.info("mmu_server: Running MMU enhanced version of metadata") + + # We need to re-parse arguments anyway, so this way, whilst relaxing need to copy code, isn't useful + #runpy.run_module('metadata', run_name="__main__", alter_sys=True) + + # Parse start arguments (copied from metadata.py) + parser = argparse.ArgumentParser(description="GCode Metadata Extraction Utility") + parser.add_argument("-c", "--config", metavar='', default=None, help="Optional json configuration file for metadata.py") + parser.add_argument("-f", "--filename", metavar='', help="name gcode file to parse") + parser.add_argument("-p", "--path", default=os.path.abspath(os.path.dirname(__file__)), metavar='', help="optional absolute path for file") + parser.add_argument("-u", "--ufp", metavar="", default=None, help="optional path of ufp file to extract") + parser.add_argument("-o", "--check-objects", dest='check_objects', action='store_true', help="process gcode file for exclude object functionality") + parser.add_argument("-m", "--placeholders", dest='placeholders', action='store_true', help="process happy hare mmu placeholders") + parser.add_argument("-n", "--nextpos", dest='nextpos', action='store_true', help="add next position to tool change") + args = parser.parse_args() + config: Dict[str, Any] = {} + if args.config is None: + if args.filename is None: + metadata.logger.info( + "The '--filename' (-f) option must be specified when " + " --config is not set" + ) + sys.exit(-1) + config["filename"] = args.filename + config["gcode_dir"] = args.path + config["ufp_path"] = args.ufp + config["check_objects"] = args.check_objects + else: + # Config file takes priority over command line options + try: + with open(args.config, "r") as f: + config = (json.load(f)) + except Exception: + metadata.logger.info(traceback.format_exc()) + sys.exit(-1) + if config.get("filename") is None: + metadata.logger.info("The 'filename' field must be present in the configuration") + sys.exit(-1) + if config.get("gcode_dir") is None: + config["gcode_dir"] = os.path.abspath(os.path.dirname(__file__)) + enabled_msg = "enabled" if config["check_objects"] else "disabled" + metadata.logger.info(f"Object Processing is {enabled_msg}") + + # Parsing for mmu placeholders and next pos insertion. We do this first so we can add additonal metadata + main(config["gcode_dir"], config["filename"], args.placeholders, args.nextpos) + + # Original metadata parser + metadata.main(config) diff --git a/config/addons/README.md b/config/addons/README.md new file mode 100644 index 000000000000..be3d5ddd80e9 --- /dev/null +++ b/config/addons/README.md @@ -0,0 +1,7 @@ +# Addons +This directory contains recommended optional addons for your MMU setup. See the doc in the [Happy Hare Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Addon-Feature-Setup) + +
+ +> [!IMPORTANT] +> For all add-on extensions, ensure that you always use the "cfg" files from Happy Hare and not those sourced elsewhere so you have the most recent changes and fixes. diff --git a/config/addons/blobifier.cfg b/config/addons/blobifier.cfg new file mode 100644 index 000000000000..6c29fea61782 --- /dev/null +++ b/config/addons/blobifier.cfg @@ -0,0 +1,1053 @@ +# Include servo hardware definition separately to allow for automatic upgrade +[include blobifier_hw.cfg] + +########################################################################################## + +# Sample config to be used in conjunction with Blobifier Purge Tray, Bucket & Nozzle +# Scrubber mod. Created by Dendrowen (dendrowen on Discord). The Macro is based on a +# version, and Nozzle Scrubber is made by Hernsl (hernsl#8860 on Discord). The device is +# designed around a Voron V2.4 300mm, but should work for 250mm and 350mm too. This +# version only supports the assembly on the rear-left of the bed. If you decide to change +# that, please consider contributing to the project by creating a pull request with the +# needed changes. + +# IMPORTANT: The rear-left part of your bed becomes unusable by this mod because the +# toolhead needs to lower down to 0. Be sure not to use the left-rear 130x35mm. + +# The goals of this combination of devices is to dispose of purged filament during a +# multicolored print without the need of a purge block and without the flurries of +# filament poops consuming your entire 3D printer room. The Blobifier achieves that by +# purging onto a retractable tray which causes the filament to turn into a tiny blob +# rather then a large spiral. This keeps the waste relatively small. The bucket should be +# able to account for up to 200 filament swaps (for the 300mm V2). + +# The Blobifier uses some room at the back-left side of your printer, depending on your +# printer limits and positions. (usually max_pos.y - toolhead_y and brush_start + +# brush_width + toolhead_x). If you do place objects within this region, Blobifier will +# skip purging automatically. It does this by extending the EXCLUDE_OBJECT_* macro's, so +# make sure you have exclude objects enabled in your slicer. + +# If your using Blobifier in conjunction with the filament cutter on the stealthburner +# toolhead, you can place the pin at max_pos.y - 7 (e.g., max pos y is 307, place it at +# 300). The pin will then poke through the cavity in your toolhead. (Be careful with +# manually moving the toolhead. I have broken many filament cutter pins) + +# It is advised to use the start_gcode from Happy Hare. Then you will be able to fully +# and efficiently use this mod. Check the Happy Hare document at gcode_preprocessing.md +# in the Happy Hare github for more details. + +###################################### DISCLAIMER ######################################## + +# You, and you alone, are responsible for the correct execution of these macros and +# gcodes. Any damage that may occur to your machine remains your responsibility. +# Especially when executing this macro for the first few times, keep an eye on your +# printer and the +# emergency stop. + +########################################################################################## + +########################################################################################## +# Main macro. Call this from the purge_macro setting in 'mmu_parameters.cfg'. Previously +# it was suggested to hook in via the post_load macro, but that method is now deprecated +# and can lead to synchroniztion/servo movement issues. +# +# purge_macro : `BLOBIFIER` +# +# Notes on parameters: +# PURGE_LENGTH=[float] (optional) The length to purge. If omitted (default) it will check +# the purge_volumes matrix or variable_purge_length. This can be used +# to override and for testing. +# +[gcode_macro BLOBIFIER] +# These parameters define your filament purging. +# Note that the control of retraction is set in 'mmu_macro_vars.cfg' which can be increased +# if you experience excessive oozing. +variable_purge_spd: 400 # Speed, in mm/min, of the purge. +variable_purge_temp_min: 200 # Minimum nozzle purge temperature. +variable_toolhead_x: 70 # From the nozzle to the left of your toolhead +variable_toolhead_y: 50 # From the nozzle to the front of your toolhead + +# This macro will prevent a gcode movement downward while 'blobbing' if there might be a +# print in the way (e.g. You print something large and need the area where Blobifier does +# its... 'business'). However, at low heights (or at print start) this might not be +# desireable. You can force a 'safe descend' with this variable. Keep in mind that the +# height of the print is an estimation based on previous heights and certain assumptions +# so it might be wise to include a safety margin of 0.2mm +variable_force_safe_descend_height_until: 1.0 + +# Adjust this so that your nozzle scrubs within the brush. Be careful not to go too low! +# Start out with a high value (like, 6) and go down from there. +# Set this to None if you have a gantry mounted brush and do not want move Z axis for brushing. +variable_brush_top: 6 + +# These parameters define your scrubbing, travel speeds, safe z clearance and how many +# times you want to wipe. Update as necessary. +variable_clearance_z: 2 # When traveling, but not cleaning, the + # clearance along the z-axis between nozzle + # and brush. +variable_wipe_qty: 2 # Number of complete (A complete wipe: left, + # right, left OR right, left, right) wipes. +variable_travel_spd_xy: 10000 # Travel (not cleaning) speed along x and + # y-axis in mm/min. +variable_travel_spd_z: 1000 # Travel (not cleaning) speed along z axis + # in mm/min. +variable_wipe_spd_xy: 10000 # Nozzle wipe speed in mm/min. + +# The acceleration to use when using the brush action. If set to 0, it uses the already +# set acceleration. However, in some cases this is not desirable for the last motion +# could be an 'outer contour' acceleration which is usually lower. +variable_brush_accel: 0 + +# Blobifier sends the toolhead to the maximum y position during purge operations and +# minimum x position during shake operations. This can cause issues when skew correction +# is set up. If you have skew correction enabled and get 'move out of range' errors +# regarding blobifier while skew is enabled, try increasing this value. Keep the +# adjustments small though! (0.1mm - 0.5mm) and increase it until it works. +variable_skew_correction: 0.1 + +# offset subtracted from the Y max position to position the toolhead during purging. +variable_y_offset: 0 + +# These parameters define the size of the brush. Update as necessary. A visual reference +# is provided below. +# +# ← brush_width → +# _________________ +# | | ↑ Y position is acquired from your +# brush_start (x) | | brush_depth stepper_y position_max. Adjust +# |_________________| ↓ your brush physically in Y so +# (y) that the nozzle scrubs within the +# brush_front brush. +# __________________________________________________________ +# PRINTER FRONT +# +# +# Start location of the brush. Defaults for 250, 300 and 350mm are provided below. +# Uncomment as necessary +#variable_brush_start: 34 # For 250mm build +variable_brush_start: 67 # For 300mm build +#variable_brush_start: 84 # for 350mm build + +# width of the brush +variable_brush_width: 35 + +# offset subtracted from the y max to perform brushing. +variable_brush_y_offset: 0 + +# Location of where to purge. The tray is 15mm in length, so if you assemble it against +# the side of the bed (default), 10mm is a good location +variable_purge_x: 10 + +# Height of the tray. If it's below your bed, give this a negative number equal to the +# difference. If it's above your bed, give it a positive number. You can find this number +# by homing, optional QGL or equivalent, and moving you toolhead above the tray, and +# lowering it with the paper method. +variable_tray_top: 0.7 + +# Servo angles for tray positions +variable_tray_angle_out: 0 +variable_tray_angle_in: 180 + +# Increase this value if the servo doesn't have enough time to fully retract or extend +variable_dwell_time: 200 + +# ======================================================================================== +# ==================== BLOB TUNING ======================================================= +# ======================================================================================== + +# The following section defines how the purging sequence is executed. This is where you +# tune the purging to create pretty blobs. Refer to the visual reference for a better +# understanding. The visual is populated with example values. Below are some guides +# provided to help with tuning. +# +# \_____________/ +# |___|___| +# \_/ ______________ < End of third iteration. +# / \ HEIGHT: 3 x iteration_z_raise - (2 + 1) x iteration_z_change (3 x 5 - 2 x 1.2 = 11.4) +# | | EXTRUDED: 3 x max_iteration_length (3 x 50 = 150) +# / \ ______________ < End of second iteration. +# | \ HEIGHT: 2 x iteration_z_raise - 1 x iteration_z_change (2 x 5 - 1 x 1.2 = 8.8) +# / | EXTRUDED: 2 x max_iteration_length (2 x 50 = 100) +# | \ ______________ < End of first iteration. +# / \ HEIGHT: 1 x iteration_z_raise (1 x 5 = 5) +# | | EXTRUDED: 1 x max_iteration_length (1 x 50 = 50) +#___________ \ / ______________ < Start height of the nozzle. default value: 1.5mm +# |_______________\___________/_ ______________ < Bottom of the tray +# |_____________________________| +# | +# +########################### BLOB TUNING ############################## +# +-------------------------------------+----------------------------+ +# | Filament sticks to the nozzle at | Incr. purge start | +# | initial purge (first few mm) | | +# +-------------------------------------+----------------------------+ +# | Filament scoots out from under | Incr. temperature | +# | the nozzle at the first iteration | Decr. z_raise | +# | | Incr. purge_length_maximum | +# +-------------------------------------+----------------------------+ +# | Filament scoots out from under the | Decr. purge_spd | +# | the nozzle at later iterations | Decr. z_raise_exp | +# | | Decr. z_raise | +# | | Incr. purge_length_maximum | +# +-------------------------------------+----------------------------+ +# | Filament sticks to the nozzle at | Incr. z_raise_exp | +# | later iterations | (Not above 1) | +# +-------------------------------------+----------------------------+ +# + +# The height to raise the nozzle above the tray before purging. This allows any built up +# pressure to escape before the purge. +variable_purge_start: 0.2 + +# The amount to raise Z +variable_z_raise: 12 + +# As the nozzle gets higher and the blob wider, the Z raise needs to be reduced, this +# follows the following formula: +# (extruded_amount/max_purge_length)^z_raise_exp * z_raise +# 1 is linear, below 1 will cause z to raise less quickly over time, above 1 will make it +# raise quicker over time. 0.85 is a good starting point and you should not have it above 1 +variable_z_raise_exp: 0.85 + +# Lift the nozzle slightly after creating the blob te release pressure on the tray. +variable_eject_hop: 1.0 + +# Dwell time (ms) after purging and before cleaning to relieve pressure from the nozzle. +variable_pressure_release_time: 1000 + +# Amount (mm) to retract between blobs to reduce string formation and allow for a cleaner purge. +variable_retract_between_blobs: 2 + +# Set the part cooling fan speed during purging (variable_part_cooling_fan) and +# during the blob depositing process (variable_part_cooling_fan_blob_deposit) +# Low fan speeds during purging help ensure the pellets are well formed and reduces +# the probability of the pellets coming unstuck from tray due to material shrinkage. +# Setting the blob depositing fan speed to high (100%) helps freeze the blob, unstick +# the blob from the nozzle and shrink it a little allowing for a cleaner blob depositing + +variable_part_cooling_fan: 0.3 # Fan speed to use during purging (-1 is unchanged, 0 is off, 0.1-1.0 for fan speed %) +variable_part_cooling_fan_blob_deposit: 1 # Fan speed to use during blob depositing (-1 is unchanged, 0 is off, 0.1-1.0 for fan speed % + +# Define the part fan name if you are using a fan other than [fan] +# Applies to [fan_generic] or other fan definitons +# Example would be if you are using auxiliary fan control in Orcaslicer (https://github.com/SoftFever/OrcaSlicer/wiki/Auxiliary-fan) +# If you are unsure if you need this, then probably just leave it commented out. + +#variable_fan_name: "fan_generic fan0" + + + +# ======================================================================================== +# ==================== PURGE LENGTH TUNING =============================================== +# ======================================================================================== + +# The absolute minimum to purge, even if you don't changed tools. This is to prime the +# nozzle before printing +variable_purge_length_minimum: 30 + +# The maximum amount of filament (in mm¹) to purge in a single blob. Blobifier will +# automatically purge multiple blobs if the purge amount exceeds this. +variable_purge_length_maximum: 150 + +# Default purge length to fall back on when neither the tool map purge_volumes or +# parameter PURGE_LENGTH is set. +variable_purge_length: 150 + +# The slicer values often are a bit too wasteful. Tune it here to get optimal values. +# 0.6 (60%) is a good starting point. +variable_purge_length_modifier: 0.6 + +# Fixed length of filament to add after the purge volume calculation. Happy Hare already +# shares info on the extra amount of filament to purge based on known residual filament, +# tip cutting fragment and initial retraction setting. However this setting can add a fixed +# amount on top on that if necessary although it is recommended to start with 0 and tune +# slicer purge matrix first. +# When should you alter this value: +# INCREASE: When the dark to light swaps are good, but light to dark aren't. +# DECREASE: When the light to dark swaps are good, but dark to light aren't. Don't +# forget to increase the purge_length_modifier +variable_purge_length_addition: 0 + +# ======================================================================================== +# ==================== BUCKET ============================================================ +# ======================================================================================== + +# Maximum number of blobs that fit in the bucket. Pauses the print if it exceeds this +# number. +variable_max_blobs: 400 +# Enable the bucket shaker. You need to have the shaker.stl installed +variable_enable_shaker: 1 +# Shaker position offset in X (relative to zero) +# Allows negative positions. Add skew_correction separately if needed. +variable_shaker_pos_x: 0 +# The number of back-and-forth motions of one shake +variable_bucket_shakes: 10 +# During shaking acceleration can often be higher because you don't need to keep print +# quality in mind. Higher acceleration helps better with dispersing the blobs. +variable_shake_accel: 10000 + +# The frequency at which to shake the bucket. A decimal value ranging from 0 to 1, where 0 +# is never, and 1 is every time. This way the shaking occurs more often as the bucket +# fills up. Sensible values range from 0.75 to 0.95 +variable_bucket_shake_frequency: 0.95 + +# Height of the shaker arm. If your hotend hits your tray during shaking, increase. +variable_shaker_arm_z: 2 + +gcode: + + # ====================================================================================== + # ==================== RECORD STATE (INCL. FANS, SPEEDS, ETC...) ======================= + # ====================================================================================== + + # General state + SAVE_GCODE_STATE NAME=BLOBIFIER_state + + + # ====================================================================================== + # ==================== CHECK HOMING STATUS ============================================= + # ====================================================================================== + + {% if "xyz" not in printer.toolhead.homed_axes %} + RESPOND MSG="BLOBIFIER: Not homed! Home xyz before blobbing" + {% elif printer.quad_gantry_level and printer.quad_gantry_level.applied == False %} + RESPOND MSG="BLOBIFIER: QGL not applied! run quad_gantry_level before blobbing" + {% else %} + + + # Always backup part cooling fan speed + {% set fan = fan_name|string %} + # Save the part cooling fan speed to be enabled again later + {% set backup_fan_speed = (printer[fan].speed if printer[fan] is defined else printer.fan.speed) %} + + # Part cooling fan set for purging + {% if part_cooling_fan >= 0 %} + # Set part cooling fan speed + M106 S{part_cooling_fan * 255} + {% endif %} + + # Set feedrate to 100% for correct speed purging + {% set backup_feedrate = printer.gcode_move.speed_factor %} + M220 S100 + + # Backup and zero Pressure Advance during blobbing + {% set backup_pa = printer.extruder.pressure_advance|default(0.0)|float %} + RESPOND MSG={"'BLOBIFIER: Setting PA to zero (was %.4f)'" % (backup_pa)} + SET_PRESSURE_ADVANCE ADVANCE=0 + + + # ====================================================================================== + # ==================== DEFINE BASIC VARIABLES ========================================== + # ====================================================================================== + + {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set filament_diameter = printer.configfile.config.extruder.filament_diameter|float %} + {% set filament_cross_section = (filament_diameter/2) ** 2 * 3.1415 %} + {% set from_tool = printer.mmu.last_tool %} + {% set to_tool = printer.mmu.tool %} + {% set bl_count = printer['gcode_macro _BLOBIFIER_COUNT'] %} + {% set pos = printer.gcode_move.gcode_position %} + {% set safe = printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'] %} + {% set ignore_safe = safe.print_height < force_safe_descend_height_until %} + {% set restore_z = [printer['gcode_macro BLOBIFIER_PARK'].restore_z,pos.z]|max %} + {% set pos_max = printer.toolhead.axis_maximum %} + {% set position_y = pos_max.y - skew_correction - y_offset %} + + # Get purge volumes from the slicer (if set up right. see + # https://github.com/moggieuk/Happy-Hare/wiki/Gcode-Preprocessing) + {% set pv = printer.mmu.slicer_tool_map.purge_volumes %} + + # ====================================================================================== + # ==================== DETERMINE PURGE LENGTH ========================================== + # ====================================================================================== + + {% if params.PURGE_LENGTH %} # =============== PARAM PURGE LENGTH ====================== + {action_respond_info("BLOBIFIER: param PURGE_LENGTH provided")} + {% set purge_len = params.PURGE_LENGTH|float %} + {% elif from_tool == to_tool and to_tool >= 0 %} # ==== TOOL DIDN'T CHANGE ============= + {action_respond_info("BLOBIFIER: Tool didn't change (T%s > T%s), %s" % (from_tool, to_tool, "priming" if purge_length_minimum else "skipping"))} + {% set purge_len = 0 %} + + {% elif pv %} # ============== FETCH FROM HAPPY HARE (LIKELY FROM SLICER) ============== + {% if from_tool < 0 and to_tool >= 0%} + {action_respond_info("BLOBIFIER: from tool unknown. Finding largest value for T? > T%d" % to_tool)} + {% set purge_vol = pv|map(attribute=to_tool)|max %} + {% elif to_tool < 0 %} + {action_respond_info("BLOBIFIER: tool(s) unknown. Finding largest value")} + {% set purge_vol = pv|map('max')|max %} + {% else %} + {% set purge_vol = pv[from_tool][to_tool]|float * purge_length_modifier %} + {action_respond_info("BLOBIFIER: Swapped T%s > T%s" % (from_tool, to_tool))} + {% endif %} + {% set purge_len = purge_vol / filament_cross_section %} + + {% set purge_len = purge_len + printer.mmu.extruder_filament_remaining + park_vars.retracted_length + purge_length_addition %} + + {% else %} # ========================= USE CONFIG VARIABLE ============================= + {action_respond_info("BLOBIFIER: No toolmap or PURGE_LENGTH. Using default")} + {% set purge_len = purge_length|float + printer.mmu.extruder_filament_remaining + park_vars.retracted_length %} + {% endif %} + + # ==================================== APPLY PURGE MINIMUM ============================= + {% set purge_len = [purge_len,purge_length_minimum]|max|round(0, 'ceil')|int %} + {action_respond_info("BLOBIFIER: Purging %dmm of filament" % (purge_len))} + + # ====================================================================================== + # ==================== PURGING SEQUENCE ================================================ + # ====================================================================================== + + # Set to absolute positioning. + G90 + + # Check for purge length and purge if necessary. + {% if purge_len|float > 0 %} + + # ==================================================================================== + # ==================== POSITIONING =================================================== + # ==================================================================================== + + # Retract the tray so it is not in the way + BLOBIFIER_SERVO POS=in + + # Move to the assembly, first a bit more to the right (brush_start) to avoid a + # potential filametrix pin if it's not already on the same Y coordinate. + {% if printer.toolhead.position.y != position_y %} + G1 X{[brush_start - 20, 30]|max} Y{position_y} F{travel_spd_xy} + {% endif %} + + # ==================================================================================== + # ==================== BUCKET SHAKE ================================================== + # ==================================================================================== + + {% if enable_shaker and (safe.shake or ignore_safe) %} + {% if (bl_count.current_blobs + 1) >= bl_count.next_shake %} + BLOBIFIER_SHAKE_BUCKET SHAKES={bucket_shakes} + _BLOBIFIER_CALCULATE_NEXT_SHAKE + {% endif %} + {% endif %} + + # ==================================================================================== + # ==================== POSITIONING ON TRAY =========================================== + # ==================================================================================== + {% if safe.tray or ignore_safe %} + G1 Z{tray_top + purge_start} F{travel_spd_z} + {% endif %} + + # Move over to the tray after z change (For cases when the tool is lower than the tray) + G1 X{purge_x} F{travel_spd_xy} + + # Extend the tray + BLOBIFIER_SERVO POS=out + + # ==================================================================================== + # ==================== HEAT HOTEND =================================================== + # ==================================================================================== + + {% if printer.extruder.temperature < purge_temp_min %} + {% if printer.extruder.target < purge_temp_min %} + M109 S{purge_temp_min} + {% else %} + TEMPERATURE_WAIT SENSOR=extruder MINIMUM={purge_temp_min} + {% endif %} + {% endif %} + + # ==================================================================================== + # ==================== START ITERATING =============================================== + # ==================================================================================== + + # Calculate total number of iterations based on the purge length and the max_iteration + # length. + {% set blobs = (purge_len / purge_length_maximum)|round(0, 'ceil')|int %} + {% set purge_per_blob = purge_len|float / blobs %} + {% set retracts_per_blob = (purge_per_blob / 40)|round(0, 'ceil')|int %} + {% set purge_per_retract = (purge_per_blob / retracts_per_blob)|int %} + {% set pulses_per_retract = (purge_per_blob / retracts_per_blob / 5)|round(0, 'ceil')|int %} + {% set pulses_per_blob = (purge_per_blob / 5)|round(0, 'ceil')|int %} + {% set purge_per_pulse = purge_per_blob / pulses_per_blob %} + {% set pulse_time_constant = purge_per_pulse * 0.95 / purge_spd / (purge_per_pulse * 0.95 / purge_spd + purge_per_pulse * 0.05 / 50) %} + {% set pulse_duration = purge_per_pulse / purge_spd %} + + # Repeat the process until purge_len is reached + {% for blob in range(blobs) %} + RESPOND MSG={"'BLOBIFIER: Blob %d of %d (%.1fmm)'" % (blob + 1, blobs, purge_per_blob)} + + {% if safe.tray or ignore_safe %} + G1 Z{tray_top + purge_start} F{travel_spd_z} + {% endif %} + + # relative positioning + G91 + # relative extrusion + M83 + + # Un-retract other than the first purge, to re-prime nozzle + {% if not loop.first %} + M400 + __BLOBIFIER_EXTRUDER_MOVE E={retract_between_blobs} EXTRUDER_SPEED={sequence_vars.retract_speed * 60} + {% endif %} + + # Purge filament in a pulsating motion to purge the filament quicker and better + {% for pulse in range(pulses_per_blob) %} + # Calculations to determine z-speed + {% set purged_this_blob = pulse * purge_per_pulse %} + {% set z_last_pos = purge_start + ((purged_this_blob)/purge_length_maximum)**z_raise_exp * z_raise %} + {% set z_pos = purge_start + ((purged_this_blob + purge_per_pulse)/purge_length_maximum)**z_raise_exp * z_raise %} + {% set z_up = z_pos - z_last_pos %} + {% set speed = z_up / pulse_duration %} + + # Purge quickly + __BLOBIFIER_EXTRUDER_MOVE EXTRUDER_SPEED={speed} E={purge_per_pulse * 0.95} NEW_Z={z_up * pulse_time_constant} + # Purge a tiny bit slowly + __BLOBIFIER_EXTRUDER_MOVE EXTRUDER_SPEED={speed} E={purge_per_pulse * 0.05} NEW_Z={z_up * (1 - pulse_time_constant)} + + # retract and unretract filament every now and then for thorough cleaning + {% if pulse % pulses_per_retract == 0 and pulse > 0 %} + __BLOBIFIER_EXTRUDER_MOVE E=-2 EXTRUDER_SPEED=1800 + __BLOBIFIER_EXTRUDER_MOVE E=2 EXTRUDER_SPEED=800 + {% endif %} + + {% endfor %} + + # ================================================================================== + # ==================== DEPOSIT BLOB ================================================ + # ================================================================================== + + # Perform retraction + {% if loop.last %} + # This is the last blob - retract to match what Happy Hare is expecting + __BLOBIFIER_EXTRUDER_MOVE E=-{park_vars.retracted_length} EXTRUDER_SPEED={sequence_vars.retract_speed * 60} + {% else %} + # This is an in between blob - retract by the in between blobs retraction value + __BLOBIFIER_EXTRUDER_MOVE E=-{retract_between_blobs} EXTRUDER_SPEED={sequence_vars.retract_speed * 60} + {% endif %} + + + {% if safe.tray or ignore_safe %} + # Set blob depositing fan speed + {% if part_cooling_fan_blob_deposit >= 0 %} + M106 S{(part_cooling_fan_blob_deposit * 255)|int} + {% endif %} + + # Raise z a bit to relieve pressure on the blob preventing it to go sideways + G1 Z{eject_hop} F{travel_spd_z} + # Retract the tray + BLOBIFIER_SERVO POS=in + # Move the toolhead down to purge_start height lowering the blob below the tray + G90 # absolute positioning + G1 Z{tray_top} F{travel_spd_z} + + # Dwell to release pressure before cutting the blob off + M400 + G4 P{pressure_release_time} + + # Extend the tray to 'cut off' the blob and prepare for the next blob + BLOBIFIER_SERVO POS=out + BLOBIFIER_SERVO POS=in + BLOBIFIER_SERVO POS=out + # Keep track of the # of blobs + _BLOBIFIER_COUNT + + # Set purging fan speed + {% if part_cooling_fan < 0 %} + # If part cooling fan is unchanged and equal to print speed fan + M106 S{(backup_fan_speed * 255)|int} + {% elif part_cooling_fan >= 0 %} + # If specific part cooling fan speed is set + M106 S{(part_cooling_fan * 255)|int} + {% endif %} + + {% endif %} + {% endfor %} + {% endif %} + + # Adjust tension and centre sensor to ensure we don't accidentally trigger FlowGuard runout when print resumes + {% if printer.mmu.sync_feedback_enabled %} + MMU_SYNC_FEEDBACK ADJUST_TENSION=1 + {% endif %} + + {% if safe.tray or ignore_safe %} + G1 Z{tray_top + 1} F{travel_spd_z} + {% endif %} + {% if safe.brush or ignore_safe %} + BLOBIFIER_CLEAN + {% else %} + G1 X{brush_start} F{travel_spd_xy} + {% endif %} + + # ====================================================================================== + # ==================== RESTORE STATE =================================================== + # ====================================================================================== + + G90 # absolute positioning + G1 Z{restore_z} F{travel_spd_z} + + # Reset part cooling fan unconditionally + M106 S{(backup_fan_speed * 255)|int} + + # Restore feedrate + M220 S{(backup_feedrate * 100)|int} + + # Restore Pressure Advance + RESPOND MSG={"'BLOBIFIER: Restoring PA to %.4f'" % (backup_pa)} + SET_PRESSURE_ADVANCE ADVANCE={backup_pa} + {% endif %} + + # Retract the tray + BLOBIFIER_SERVO POS=in + + RESTORE_GCODE_STATE NAME=BLOBIFIER_state + +########################################################################################## +# Extrusion helper that allows for check of "runout/clog" indicator +# +[gcode_macro __BLOBIFIER_EXTRUDER_MOVE] +description: Extruder move (optional Z) with clog/runout guard +# Parameters: +# EXTRUDER_SPEED in mm/min +# E in mm +# NEW_Z in mm +gcode: + {% set extruder_speed = params.EXTRUDER_SPEED|float %} + {% set E = params.E|float %} + {% set new_z = params.NEW_Z|default(None) %} + + {% set clog_runout_detected = printer.mmu.clog_runout_detected|default(false)|lower == 'true' %} + + {% if not clog_runout_detected %} + {% if new_z is not none %} + G1 Z{new_z} E{E} F{extruder_speed} + {% else %} + G1 E{E} F{extruder_speed} + {% endif %} + {% endif %} + +########################################################################################## +# Wipes the nozzle on the brass brush +# +[gcode_macro BLOBIFIER_CLEAN] +gcode: + {% set bl = printer['gcode_macro BLOBIFIER'] %} + {% set pos_max = printer.toolhead.axis_maximum %} + {% set position_y = pos_max.y - bl.skew_correction - bl.brush_y_offset %} + {% set original_accel = printer.toolhead.max_accel %} + {% set original_minimum_cruise_ratio = printer.toolhead.minimum_cruise_ratio %} + {% set pos = printer.gcode_move.gcode_position %} + {% set min_z = (bl.brush_top or 0) + bl.clearance_z %} + + SAVE_GCODE_STATE NAME=BLOBIFIER_CLEAN_state + + G90 + + {% if bl.brush_accel > 0 %} + SET_VELOCITY_LIMIT ACCEL={bl.brush_accel} MINIMUM_CRUISE_RATIO=0.1 + {% endif %} + + # Ensure we have our clearance...we don't want any bed scraping here! + {% if pos.z < min_z %} + G1 Z{min_z} F{bl.travel_spd_z} + {% endif %} + G1 X{bl.brush_start} F{bl.travel_spd_xy} + G1 Y{position_y} + + {% if bl.brush_top is not none %} + # Ensure clearance above the brush + G1 Z{bl.brush_top + bl.clearance_z} F{bl.travel_spd_z} + + # Move nozzle down into brush. + G1 Z{bl.brush_top} F{bl.travel_spd_z} + {% endif %} + + SET_VELOCITY_LIMIT ACCEL={original_accel} MINIMUM_CRUISE_RATIO={original_minimum_cruise_ratio} + + # Perform wipe. Wipe direction based off bucket_pos for cool random scrubby routine. + {% for wipes in range(1, (bl.wipe_qty + 1)) %} + G1 X{bl.brush_start + bl.brush_width} F{bl.wipe_spd_xy} + G1 X{bl.brush_start} F{bl.wipe_spd_xy} + {% endfor %} + + # Move away from the brush, but not onto the tray or in front of the filametrix cutter pin + G1 X{[bl.brush_start - 20, 30]|max} F{bl.travel_spd_xy} + + RESTORE_GCODE_STATE NAME=BLOBIFIER_CLEAN_state + + + +########################################################################################## +# Park the nozzle on the tray to prevent oozing during filament swaps. Place this +# extension in the post_form_tip extension in mmu_macro_vars.cfg: +# variable_user_post_form_tip_extension: "BLOBIFIER_PARK" +# +[gcode_macro BLOBIFIER_PARK] +variable_restore_z: 0 +gcode: + {% set bl = printer['gcode_macro BLOBIFIER'] %} + {% set pos = printer.gcode_move.gcode_position %} + {% set safe = printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'] %} + {% set pos_max = printer.toolhead.axis_maximum %} + {% set position_y = pos_max.y - bl.skew_correction - bl.y_offset %} + + SET_GCODE_VARIABLE MACRO=BLOBIFIER_PARK VARIABLE=restore_z VALUE={pos.z} + + SAVE_GCODE_STATE NAME=blobifier_park_state + + {% if "xyz" in printer.toolhead.homed_axes and printer.quad_gantry_level and printer.quad_gantry_level.applied %} + G90 + + # Retract the tray + BLOBIFIER_SERVO POS=in + + G1 X{[bl.brush_start - 20, 30]|max} Y{position_y} F{bl.travel_spd_xy} + {% if safe.tray or ignore_safe %} + G1 Z{bl.tray_top} F{bl.travel_spd_z} + {% endif %} + G1 X{bl.purge_x} F{bl.travel_spd_xy} + + # Extend the tray + BLOBIFIER_SERVO POS=out + + {% else %} + RESPOND MSG="Please home (and QGL) before parking" + {% endif %} + + RESTORE_GCODE_STATE NAME=blobifier_park_state + +########################################################################################## +# Retract or extend the tray +# POS=[in|out] Retractor extend the tray +# +[gcode_macro BLOBIFIER_SERVO] +gcode: + {% set bl = printer['gcode_macro BLOBIFIER'] %} + {% set pos = params.POS %} + {% if pos == "in" %} + SET_SERVO SERVO=blobifier ANGLE={bl.tray_angle_in} + G4 P{bl.dwell_time} + {% elif pos == "out" %} + SET_SERVO SERVO=blobifier ANGLE={bl.tray_angle_out} + G4 P{bl.dwell_time} + {% else %} + {action_respond_info("BLOBIFIER: provide POS=[in|out]")} + {% endif %} + SET_SERVO SERVO=blobifier WIDTH=0 + +########################################################################################## +# Define exclude objects for those who haven't already +# +[exclude_object] + +########################################################################################## +# Overwrite the existing EXCLUDE_OBJECT_DEFINE to also check for safe descend. +# +[gcode_macro EXCLUDE_OBJECT_DEFINE] +rename_existing: _EXCLUDE_OBJECT_DEFINE +gcode: + # only reset on the first object at the beginning of a print + {% if printer.exclude_object.objects|length < 1 %} + _BLOBIFIER_RESET_SAFE_DESCEND + {% endif %} + _EXCLUDE_OBJECT_DEFINE {rawparams} + _BLOBIFIER_SAFE_DESCEND + UPDATE_DELAYED_GCODE ID=BLOBIFIER_SHOW_SAFE_DESCEND DURATION=1 + +[delayed_gcode BLOBIFIER_SHOW_SAFE_DESCEND] +gcode: + {% set safe = printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'] %} + {action_respond_info( + "BLOBIFIER: Safe descend possible:\n - tray: %s\n - brush: %s\n - shake: %s" % + ( + "yes" if safe.tray else "no", + "yes" if safe.brush else "no", + "yes" if safe.shake else "no" + ) + )} + +########################################################################################## +# Use the EXCLUDE_OBJECT_START gcode macro to record the current height +# +[gcode_macro EXCLUDE_OBJECT_START] +rename_existing: _EXCLUDE_OBJECT_START +gcode: + _EXCLUDE_OBJECT_START {rawparams} + {% if printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'].first_layer %} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=first_layer VALUE=False + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_height VALUE={printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'].print_layer_height} + {% else %} + {% set pos = printer.gcode_move.gcode_position %} + {% set last_height = printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'].print_previous_height|float %} + {% if pos.z > last_height %} + {% set last_layer = (pos.z - last_height)|round(2) %} + {% set print_height = (pos.z + last_layer)|round(2) %} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_previous_height VALUE={pos.z} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_height VALUE={print_height} + {% endif %} + {% endif %} + +########################################################################################## +# Reset the safe descend variables. +# +[gcode_macro _BLOBIFIER_RESET_SAFE_DESCEND] +gcode: + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=tray VALUE=True + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=brush VALUE=True + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=shake VALUE=True + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=first_layer VALUE=True + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_height VALUE=0 + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_previous_height VALUE=0 + +########################################################################################## +# Determine if it is safe to drop the toolhead (e.g. not hit a print) +# +[gcode_macro _BLOBIFIER_SAFE_DESCEND] +variable_tray: True # Assume it is safe +variable_brush: True +variable_shake: True +variable_first_layer: True +variable_print_height: 0 +variable_print_previous_height: 0 +variable_print_layer_height: 0.3 +gcode: + {% set bl = printer['gcode_macro BLOBIFIER'] %} + {% set pos_max = printer.toolhead.axis_maximum %} + {% set position_y = pos_max.y - bl.skew_correction - bl.y_offset %} + {% set brush_position_y = pos_max.y - bl.skew_correction - bl.brush_y_offset %} + {% set tray = [bl.purge_x + bl.toolhead_x, position_y - bl.toolhead_y] %} + {% set brush = [bl.brush_start + bl.brush_width + bl.toolhead_x, brush_position_y - bl.toolhead_y] %} + {% set shake = [bl.purge_x + bl.toolhead_x, position_y - bl.toolhead_y - 4] %} + {% set objects = printer.exclude_object.objects | map(attribute='polygon') %} + + {% for polygon in objects %} + {% for point in polygon %} + {% if point[0] < tray[0] and point[1] > tray[1] %} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=tray VALUE=False + {% endif %} + {% if bl.brush_top is not none and point[0] < brush[0] and point[1] > brush[1] %} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=brush VALUE=False + {% endif %} + {% if point[0] < shake[0] and point[1] > shake[1] %} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=shake VALUE=False + {% endif %} + {% endfor %} + {% endfor %} + +########################################################################################## +# Increment the blob count with 1 and check if the bucket is full. Pause +# the printer if it is. +# +[gcode_macro _BLOBIFIER_COUNT] +# Don't change these variables +variable_current_blobs: 0 +variable_last_shake: 0 +variable_next_shake: 0 +gcode: + {% set bl = printer['gcode_macro BLOBIFIER'] %} + {% set count = printer['gcode_macro _BLOBIFIER_COUNT'] %} + {% if current_blobs >= bl.max_blobs %} + {action_respond_info("BLOBIFIER: Empty purge bucket!")} + M117 Empty purge bucket! + MMU_PAUSE MSG="Empty purge bucket!" + {% else %} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=current_blobs VALUE={current_blobs + 1} + _BLOBIFIER_SAVE_STATE + {action_respond_info( + "BLOBIFIER: Blobs in bucket: %s/%s. Next shake @ %s" + % (current_blobs + 1, bl.max_blobs, next_shake) + )} + {% endif %} + +########################################################################################## +# Reset the blob count to 0 +# +[gcode_macro _BLOBIFIER_COUNT_RESET] +gcode: + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=current_blobs VALUE=0 + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=last_shake VALUE=0 + _BLOBIFIER_SAVE_STATE + + _BLOBIFIER_CALCULATE_NEXT_SHAKE + +########################################################################################## +# Shake the blob bucket to disperse the blobs +# +[gcode_macro BLOBIFIER_SHAKE_BUCKET] +gcode: + {% set bl = printer['gcode_macro BLOBIFIER'] %} + {% set count = printer['gcode_macro _BLOBIFIER_COUNT'] %} + {% set original_accel = printer.toolhead.max_accel %} + {% set original_minimum_cruise_ratio = printer.toolhead.minimum_cruise_ratio %} + {% set position_x = bl.shaker_pos_x|default(0)|float + bl.skew_correction %} + + {% if "xyz" not in printer.toolhead.homed_axes %} + {action_raise_error("BLOBIFIER: Not homed. Home xyz first")} + {% endif %} + + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=last_shake VALUE={count.current_blobs} + _BLOBIFIER_SAVE_STATE + SAVE_GCODE_STATE NAME=shake_bucket + + M400 + M117 (^_^) + + G90 + {% set shakes = params.SHAKES|default(10)|int %} + {% set pos_max = printer.toolhead.axis_maximum %} + {% set position_y = pos_max.y - bl.skew_correction - bl.y_offset %} + + # move to save y if not already there + {% if printer.toolhead.position.y != position_y %} + G1 X{bl.brush_start} Y{position_y} F{bl.travel_spd_xy} + {% endif %} + + # Retract the tray + BLOBIFIER_SERVO POS=in + + # move up a bit to prevent oozing on base + G1 Z{bl.shaker_arm_z} F{bl.travel_spd_z} + # slide into the slot + G1 X{position_x} F{bl.travel_spd_xy} + + M400 + M117 (+(+_+)+) + + SET_VELOCITY_LIMIT ACCEL={bl.shake_accel} MINIMUM_CRUISE_RATIO=0.1 + + # Shake away! + {% for shake in range(1, shakes) %} + G1 Y{position_y - 4} + G1 Y{position_y} + {% endfor %} + + SET_VELOCITY_LIMIT ACCEL={original_accel} MINIMUM_CRUISE_RATIO={original_minimum_cruise_ratio} + # move out of slot + G1 X{bl.purge_x} + + M400 + M117 (X_x) + + RESTORE_GCODE_STATE NAME=shake_bucket + +########################################################################################## +# Calculate when the bucket should be shaken. +# +[gcode_macro _BLOBIFIER_CALCULATE_NEXT_SHAKE] +gcode: + {% set bl = printer['gcode_macro BLOBIFIER'] %} + {% set count = printer['gcode_macro _BLOBIFIER_COUNT'] %} + + {% set remaining_blobs = bl.max_blobs - count.last_shake %} + {% set next_shake = (1 - bl.bucket_shake_frequency) * remaining_blobs + count.last_shake %} + _BLOBIFIER_SAVE_STATE + _BLOBIFIER_SET_NEXT_SHAKE VALUE={next_shake|int} + +########################################################################################## +# Set when the bucket should be shaken next +# VALUE=[int] At what amount of blobs should it be shaken +# +[gcode_macro _BLOBIFIER_SET_NEXT_SHAKE] +gcode: + {% if params.VALUE %} + {% set next_shake = params.VALUE %} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=next_shake VALUE={next_shake} + _BLOBIFIER_SAVE_STATE + {% else %} + {action_respond_info("BLOBIFIER: Provide parameter VALUE=")} + {% endif %} + +########################################################################################## +# Some sanity checks +# +[delayed_gcode BLOBIFIER_INIT] +initial_duration: 5.0 +gcode: + _BLOBIFIER_INIT + # Extend and retract the tray to test + BLOBIFIER_SERVO POS=out + BLOBIFIER_SERVO POS=in + +[gcode_macro _BLOBIFIER_INIT] +gcode: + {% set bl = printer['gcode_macro BLOBIFIER'] %} + {% set axis_min = printer.toolhead.axis_minimum %} + {% set axis_max = printer.toolhead.axis_maximum %} + + # Valid part cooling fan setting + {% if bl.part_cooling_fan != -1 and (bl.part_cooling_fan < 0 or bl.part_cooling_fan > 1) %} + {action_emergency_stop("BLOBIFIER: Value %f is invalid for variable part_cooling_fan. Either -1 or a value from 0 .. 1 is valid." % (bl.part_cooling_fan))} + {% endif %} + + # Valid bucket shake frequency + {% if bl.bucket_shake_frequency < 0 or bl.bucket_shake_frequency > 1 %} + {action_emergency_stop("BLOBIFIER: Value %f is invalid for variable bucket_shake_frequency. Change it to a value between 0 .. 1" % (bl.bucket_shake_frequency))} + {% endif %} + + # Check if position is on 'next' + {% if printer.mmu %} + {% if printer['gcode_macro _MMU_SEQUENCE_VARS'].restore_xy_pos != 'next' %} + {action_respond_info("BLOBIFIER: If not using a wipe tower, consider setting restore_xy_pos: 'next' in mmu_macro_vars.cfg")} + {% endif %} + {% endif %} + + # Check the z_raise variable for normal values + {% if bl.z_raise < 3 %} + {action_respond_info("BLOBIFIER: variable_z_raise: %f is very low. This is the value z raises in total on a single blob. Make sure the value is correct before continuing." % (bl.z_raise))} + {% endif %} + + # Z raise exponent + {% if bl.z_raise_exp > 1 or bl.z_raise_exp < 0.5 %} + {action_respond_info("BLOBIFIER: variable_z_raise_exp has value: %f. This value is out of spec (0.5 ... 1.0)." % (bl.z_raise_exp))} + {% endif %} + + # cap user defined accels at printer max_accel if greater + {% if bl.shake_accel > printer.configfile.config.printer.max_accel|int %} + {action_respond_info("BLOBIFIER: variable_shake_accel has value: %d which is higher than your printer limit of %d. Reduce this if your printer skips steps." % (bl.shake_accel,printer.configfile.config.printer.max_accel|int))} + {% endif %} + {% if bl.brush_accel > printer.configfile.config.printer.max_accel|int %} + {action_respond_info("BLOBIFIER: variable_brush_accel has value: %d which is higher than your printer limit of %d. Reduce this if your printer skips steps." % (bl.brush_accel,printer.configfile.config.printer.max_accel|int))} + {% endif %} + + # Check brush_top variable + _BLOBIFIER_VALIDATE_FLOAT_VARIABLE NAME=brush_top MIN={axis_min.z} MAX={axis_max.z} ALLOW_NONE=1 + +[gcode_macro _BLOBIFIER_VALIDATE_FLOAT_VARIABLE] +gcode: + {% set name = params.NAME %} + {% set min_value = params.MIN|default(None)|float(None) %} + {% set max_value = params.MAX|default(None)|float(None) %} + {% set allow_none = params.ALLOW_NONE|default(0)|int %} + {% set bl = printer['gcode_macro BLOBIFIER'] %} + + {% set value = bl[name]|float(None) %} + + # Save the normalized value back to the variable + SET_GCODE_VARIABLE MACRO=BLOBIFIER VARIABLE={name} VALUE={value} + + {% if not allow_none and value is none %} + {action_emergency_stop("BLOBIFIER: %s is not set. Please provide a value." % (name))} + {% endif %} + + # If number is less than min, then we should just emergency stop now + {% if value is number and min_value is not none and value < min_value %} + {action_emergency_stop("BLOBIFIER: %s has value: %.2f which is below min of %.2f. Adjust %s!" % (name, value, min_value, name))} + + # If number is greater than max, then we should just emergency stop now + {% elif value is number and max_value is not none and value > max_value %} + {action_emergency_stop("BLOBIFIER: %s has value: %.2f which is above max of %.2f. Adjust %s!" % (name, value, max_value, name))} + {% endif %} + +[delayed_gcode BLOBIFIER_LOAD_STATE] +initial_duration: 2.0 # Give it some time to boot up +gcode: + {% set sv = printer.save_variables.variables.blobifier %} + + {% if sv %} + # Restore state + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=last_shake VALUE={sv.last_shake} + SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=current_blobs VALUE={sv.current_blobs} + {% endif %} + _BLOBIFIER_CALCULATE_NEXT_SHAKE + +[gcode_macro _BLOBIFIER_SAVE_STATE] +gcode: + {% set count = printer['gcode_macro _BLOBIFIER_COUNT'] %} + {% set sv = {'current_blobs': count.current_blobs, 'last_shake': count.last_shake} %} + SAVE_VARIABLE VARIABLE=blobifier VALUE="{sv}" diff --git a/config/addons/blobifier_hw.cfg b/config/addons/blobifier_hw.cfg new file mode 100644 index 000000000000..5f76ae8c5c95 --- /dev/null +++ b/config/addons/blobifier_hw.cfg @@ -0,0 +1,28 @@ + +########################################################################################## +# The servo hardware configuration. Change the values to your needs. +# +[mmu_servo blobifier] +# Pin for the servo. +pin: PG14 +# Adjust this value until a 'BLOBIFIER_SERVO POS=out' extends the tray fully without a +# buzzing sound +minimum_pulse_width: 0.00053 +# Adjust this value until a 'BLOBIFIER_SERVO POS=in' retracts the tray fully without a +# buzzing sound +maximum_pulse_width: 0.0023 +# Leave this value at 180 +maximum_servo_angle: 180 + + +########################################################################################## +# The bucket hardware configuration. Change the pin to whatever pin you've connected the +# switch to. +# +[gcode_button bucket] +pin: ^PG15 # The pullup ( ^ ) is important here. +press_gcode: + M117 bucket installed +release_gcode: + M117 bucket removed + _BLOBIFIER_COUNT_RESET diff --git a/config/addons/mmu_eject_buttons.cfg b/config/addons/mmu_eject_buttons.cfg new file mode 100644 index 000000000000..afc39df6942b --- /dev/null +++ b/config/addons/mmu_eject_buttons.cfg @@ -0,0 +1,35 @@ +# Include servo hardware definition separately to allow for automatic upgrade +[include mmu_eject_buttons_hw.cfg] + +########################################################################### +# Optional hardware MMU eject buttons (e.g. QuattroBox) +# +# This is the supplementary macro to support dedicated per-gate eject +# buttons for easy unloading. It is complimentary to the built-in auto +# preload of filament +# +# To configure: +# 1. Add this to your printer.cfg: +# +# [include mmu/addons/mmu_eject_buttons.cfg] +# + +########################################################################### +# Macro to simply call MMU_EJECT for the specified gate +# +# This logic is separated from actual button h/w setup to facilitate upgrades +# and to allow addition of logic (perhaps validation or warning logic) +# +[gcode_macro _MMU_EJECT_BUTTON] +description: Wrapper around ejecting filament via dedicated hardware buttons +gcode: + {% set gate = params.GATE|default(-1)|int %} + {% set mmu = printer['mmu'] %} + {% set current_gate = mmu.gate %} + {% set is_printing = printer.mmu.print_state in ["started", "printing"] %} + + {% if not is_printing %} + MMU_EJECT GATE={gate} + {% else %} + MMU_LOG MSG="Eject operation not possible when actively printing. Pause first" ERROR=1 + {% endif %} diff --git a/config/addons/mmu_eject_buttons_hw.cfg b/config/addons/mmu_eject_buttons_hw.cfg new file mode 100644 index 000000000000..693ee579b44d --- /dev/null +++ b/config/addons/mmu_eject_buttons_hw.cfg @@ -0,0 +1,21 @@ + +########################################################################################## +# The eject button hardware configuration. Change the values to your needs and number +# of gates +# + +[gcode_button mmu_eject_button_0] +pin: mmu:EJECT_BUTTON_0 +press_gcode: _MMU_EJECT_BUTTON GATE=0 + +[gcode_button mmu_eject_button_1] +pin: mmu:EJECT_BUTTON_1 +press_gcode: _MMU_EJECT_BUTTON GATE=1 + +[gcode_button mmu_eject_button_2] +pin: mmu:EJECT_BUTTON_2 +press_gcode: _MMU_EJECT_BUTTON GATE=2 + +[gcode_button mmu_eject_button_3] +pin: mmu:EJECT_BUTTON_3 +press_gcode: _MMU_EJECT_BUTTON GATE=3 diff --git a/config/addons/mmu_erec_cutter.cfg b/config/addons/mmu_erec_cutter.cfg new file mode 100644 index 000000000000..12e1d53f0a47 --- /dev/null +++ b/config/addons/mmu_erec_cutter.cfg @@ -0,0 +1,91 @@ +# Include servo hardware definition separately to allow for automatic upgrade +[include mmu_erec_cutter_hw.cfg] + +########################################################################### +# Optional EREC Filament Cutter Support +# +# https://github.com/kevinakasam/ERCF_Filament_Cutter +# +# This is the supplementary macro to support filament cutting at the MMU +# on a ERCF design. +# +# To configure: +# 1. Add this to your printer.cfg: +# +# [include mmu/addons/mmu_erec_cutter.cfg] +# +# 2. In mmu_macro_vars.cfg, change this line: +# +# variable_user_post_unload_extension : "EREC_CUTTER_ACTION" +# +# 3. Tune the servo configuration and macro "variables" below +# + +# EREC CUTTER CONFIGURATION ----------------------------------------------- +# (addons/mmu_erec_cutter.cfg) +# +[gcode_macro _EREC_VARS] +description: Empty macro to store the variables +gcode: # Leave empty + +# These variables control the servo movement +variable_servo_closed_angle : 70 ; Servo angle for closed position with bowden aligned MMU +variable_servo_open_angle : 10 ; Servo angle to open up the cutter and move bowden away from MMU +variable_servo_duration : 1.5 ; Time (s) of PWM pulse train to activate servo +variable_servo_idle_time : 1.8 ; Time (s) to let the servo to reach it's position + +# Controls for feed and cut lengths +variable_feed_length : 48 ; Distance in mm from gate parking position to blade (ERCFv1.1: 58, v2/other: 48) +variable_cut_length : 10 ; Amount in mm of filament to cut +variable_cut_attempts : 1 ; Number of times the cutter tries to cut the filament + + +########################################################################### +# Macro to perform the cutting step. Designed to be included to the +# _MMU_POST_UNLOAD step +# +[gcode_macro EREC_CUTTER_ACTION] +description: Cut off the filament tip at the MMU after the unload sequence is complete +gcode: + {% set vars = printer["gcode_macro _EREC_VARS"] %} + + MMU_LOG MSG="Cutting filament tip..." + + _CUTTER_OPEN + _MMU_STEP_MOVE MOVE={vars.feed_length + vars.cut_length} + {% for i in range(vars.cut_attempts - 1) %} + _CUTTER_CLOSE + _CUTTER_OPEN + {% endfor %} + _MMU_STEP_MOVE MOVE=-1 + _CUTTER_CLOSE + _MMU_EVENT EVENT="filament_cut" # Count as one cut for consumption counter + + _MMU_STEP_SET_FILAMENT STATE=2 # FILAMENT_POS_START_BOWDEN + _MMU_STEP_UNLOAD_GATE # Repeat gate parking move + _MMU_M400 # Wait on both move queues + +[gcode_macro _CUTTER_ANGLE] +description: Helper macro to set cutter servo angle +gcode: + {% set angle = params.ANGLE|default(0)|int %} + SET_SERVO SERVO=cut_servo ANGLE={angle} + +[gcode_macro _CUTTER_CLOSE] +description: Helper macro to set cutting servo the closed position +gcode: + {% set vars = printer["gcode_macro _EREC_VARS"] %} + SET_SERVO SERVO=cut_servo ANGLE={vars.servo_closed_angle} DURATION={vars.servo_duration} + G4 P{vars.servo_idle_time * 1000} + RESPOND MSG="EREC Cutter closed" + M400 + +[gcode_macro _CUTTER_OPEN] +description: Helper macro to set cutting servo the open position +gcode: + {% set vars = printer["gcode_macro _EREC_VARS"] %} + SET_SERVO SERVO=cut_servo ANGLE={vars.servo_open_angle} DURATION={vars.servo_duration} + G4 P{vars.servo_idle_time * 1000} + RESPOND MSG="EREC Cutter open" + M400 + diff --git a/config/addons/mmu_erec_cutter_hw.cfg b/config/addons/mmu_erec_cutter_hw.cfg new file mode 100644 index 000000000000..4fec0c18d017 --- /dev/null +++ b/config/addons/mmu_erec_cutter_hw.cfg @@ -0,0 +1,10 @@ + +########################################################################################## +# The servo hardware configuration. Change the values to your needs. +# +[mmu_servo cut_servo] +pin: mmu:PA7 # Extra Pin on the ERCF easy Board +maximum_servo_angle: 180 # Set this to 60 for a 60° Servo +minimum_pulse_width: 0.0005 # Adapt these for your servo +maximum_pulse_width: 0.0025 # Adapt these for your servo + diff --git a/config/base/mmu.cfg b/config/base/mmu.cfg new file mode 100644 index 000000000000..0a0789eda3c9 --- /dev/null +++ b/config/base/mmu.cfg @@ -0,0 +1,123 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Happy Hare MMU hardware pin config +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# This contains aliases for pins for MCU type {brd_type} +# +[mcu mmu] +serial: {serial} # Change to `canbus_uuid: 1234567890` for CANbus setups + +# If using proportional sync-feedback sensor (like FPS), define mcu connection here +#[mcu fps] +#canbus_uuid: +#canbus_interface: can0 + + +# PIN ALIASES FOR MMU MCU BOARD ---------------------------------------------------------------------------------------- +# ██████╗ ██╗███╗ ██╗ █████╗ ██╗ ██╗ █████╗ ███████╗ +# ██╔══██╗██║████╗ ██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝ +# ██████╔╝██║██╔██╗ ██║ ███████║██║ ██║███████║███████╗ +# ██╔═══╝ ██║██║╚██╗██║ ██╔══██║██║ ██║██╔══██║╚════██║ +# ██║ ██║██║ ╚████║ ██║ ██║███████╗██║██║ ██║███████║ +# ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝╚══════╝ +# Section to create alias for pins used by MMU for easier integration into Klippain and RatOS. The names match those +# referenced in the mmu_hardware.cfg file. If you get into difficulty you can also comment out this aliases definition +# completely and configure the pin names directly into mmu_hardware.cfg. However, use of aliases is encouraged. + +# Note: that aliases are not created for TOOLHEAD_SENSOR, EXTRUDER_SENSOR or SYNC_FEEDBACK_SENSORS because those are +# most likely on the printer's main mcu. These should be set directly in mmu_hardware.cfg +# +[board_pins mmu] +mcu: mmu # Assumes using an external / extra mcu dedicated to MMU +aliases: + MMU_GEAR_UART={gear_uart_pin}, + MMU_GEAR_STEP={gear_step_pin}, + MMU_GEAR_DIR={gear_dir_pin}, + MMU_GEAR_ENABLE={gear_enable_pin}, + MMU_GEAR_DIAG={gear_diag_pin}, + + MMU_GEAR_UART_1={gear_1_uart_pin}, + MMU_GEAR_STEP_1={gear_1_step_pin}, + MMU_GEAR_DIR_1={gear_1_dir_pin}, + MMU_GEAR_ENABLE_1={gear_1_enable_pin}, + MMU_GEAR_DIAG_1={gear_1_diag_pin}, + + MMU_GEAR_UART_2={gear_2_uart_pin}, + MMU_GEAR_STEP_2={gear_2_step_pin}, + MMU_GEAR_DIR_2={gear_2_dir_pin}, + MMU_GEAR_ENABLE_2={gear_2_enable_pin}, + MMU_GEAR_DIAG_2={gear_2_diag_pin}, + + MMU_GEAR_UART_3={gear_3_uart_pin}, + MMU_GEAR_STEP_3={gear_3_step_pin}, + MMU_GEAR_DIR_3={gear_3_dir_pin}, + MMU_GEAR_ENABLE_3={gear_3_enable_pin}, + MMU_GEAR_DIAG_3={gear_3_diag_pin}, + + MMU_SEL_UART={selector_uart_pin}, + MMU_SEL_STEP={selector_step_pin}, + MMU_SEL_DIR={selector_dir_pin}, + MMU_SEL_ENABLE={selector_enable_pin}, + MMU_SEL_DIAG={selector_diag_pin}, + MMU_SEL_ENDSTOP={selector_endstop_pin}, + MMU_SEL_SERVO={selector_servo_pin}, + + MMU_ENCODER={encoder_pin}, + MMU_GATE_SENSOR={gate_sensor_pin}, + MMU_NEOPIXEL={neopixel_pin}, + + MMU_PRE_GATE_0={pre_gate_0_pin}, + MMU_PRE_GATE_1={pre_gate_1_pin}, + MMU_PRE_GATE_2={pre_gate_2_pin}, + MMU_PRE_GATE_3={pre_gate_3_pin}, + MMU_PRE_GATE_4={pre_gate_4_pin}, + MMU_PRE_GATE_5={pre_gate_5_pin}, + MMU_PRE_GATE_6={pre_gate_6_pin}, + MMU_PRE_GATE_7={pre_gate_7_pin}, + MMU_PRE_GATE_8={pre_gate_8_pin}, + MMU_PRE_GATE_9={pre_gate_9_pin}, + MMU_PRE_GATE_10={pre_gate_10_pin}, + MMU_PRE_GATE_11={pre_gate_11_pin}, + + MMU_POST_GEAR_0={gear_sensor_0_pin}, + MMU_POST_GEAR_1={gear_sensor_1_pin}, + MMU_POST_GEAR_2={gear_sensor_2_pin}, + MMU_POST_GEAR_3={gear_sensor_3_pin}, + MMU_POST_GEAR_4={gear_sensor_4_pin}, + MMU_POST_GEAR_5={gear_sensor_5_pin}, + MMU_POST_GEAR_6={gear_sensor_6_pin}, + MMU_POST_GEAR_7={gear_sensor_7_pin}, + MMU_POST_GEAR_8={gear_sensor_8_pin}, + MMU_POST_GEAR_9={gear_sensor_9_pin}, + MMU_POST_GEAR_10={gear_sensor_10_pin}, + MMU_POST_GEAR_11={gear_sensor_11_pin}, + + MMU_ESPOOLER_RWD_0={espooler_rwd_0_pin}, + MMU_ESPOOLER_FWD_0={espooler_fwd_0_pin}, + MMU_ESPOOLER_EN_0={espooler_en_0_pin}, + MMU_ESPOOLER_TRIG_0=, + MMU_ESPOOLER_RWD_1={espooler_rwd_1_pin}, + MMU_ESPOOLER_FWD_1={espooler_fwd_1_pin}, + MMU_ESPOOLER_EN_1={espooler_en_1_pin}, + MMU_ESPOOLER_TRIG_1=, + MMU_ESPOOLER_RWD_2={espooler_rwd_2_pin}, + MMU_ESPOOLER_FWD_2={espooler_fwd_2_pin}, + MMU_ESPOOLER_EN_2={espooler_en_2_pin}, + MMU_ESPOOLER_TRIG_2=, + MMU_ESPOOLER_RWD_3={espooler_rwd_3_pin}, + MMU_ESPOOLER_FWD_3={espooler_fwd_3_pin}, + MMU_ESPOOLER_EN_3={espooler_en_3_pin}, + MMU_ESPOOLER_TRIG_3=, + diff --git a/config/base/mmu.cfg.kms b/config/base/mmu.cfg.kms new file mode 100644 index 000000000000..e0aa6795e7d7 --- /dev/null +++ b/config/base/mmu.cfg.kms @@ -0,0 +1,33 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Happy Hare MMU hardware pin config +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# This contains MCU connection details for KMS (and KMS buffer) +# Change `serial` to `canbus_uuid: 1234567890` for CANbus setups +# +[mcu mmu] +serial: {serial1} + +[mcu buffer] +serial: {serial2} + +[temperature_sensor MCU_mmu] +sensor_type: temperature_mcu +sensor_mcu: mmu + +[temperature_sensor MCU_buffer] +sensor_type: temperature_mcu +sensor_mcu: buffer + diff --git a/config/base/mmu.cfg.vvd b/config/base/mmu.cfg.vvd new file mode 100644 index 000000000000..61ee7f7698e9 --- /dev/null +++ b/config/base/mmu.cfg.vvd @@ -0,0 +1,35 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Happy Hare MMU hardware pin config +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# This contains MCU connection details for KMS (and KMS buffer) +# Change `serial` to `canbus_uuid: 1234567890` for CANbus setups +# +[mcu mmu] +serial: {serial1} +#serial: /dev/serial/by-id/usb-Klipper_stm32g0b1xx_4F0034000A50425539393020-if00 + +[mcu buffer] +serial: {serial2} +#serial: /dev/serial/by-id/usb-Klipper_stm32f042x6_buffer-if00 + +[temperature_sensor MCU_mmu] +sensor_type: temperature_mcu +sensor_mcu: mmu + +[temperature_sensor MCU_buffer] +sensor_type: temperature_mcu +sensor_mcu: buffer + diff --git a/config/base/mmu_cut_tip.cfg b/config/base/mmu_cut_tip.cfg new file mode 100644 index 000000000000..4201a6aa774b --- /dev/null +++ b/config/base/mmu_cut_tip.cfg @@ -0,0 +1,296 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Standalone Tip Cutting for "Filametrix" style toolhead cutters +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# When using this macro it is important to turn off tip forming in your slicer +# (read the wiki: Slicer Setup & Toolchange-Movement pages) +# Then set the following parameters in mmu_parameters.cfg: +# +# form_tip_macro: _MMU_CUT_TIP +# force_form_tip_standalone: 1 +# +# This will ensure this macro is always called either in out of a print +# +# NOTE: +# The park position of the filament is relative to the nozzle and +# represents where the end of the filament is after cutting. The park position +# is important and used by Happy Hare both to finish unloading the extruder +# as well as to calculate how far to advance the filament on the subsequent load. +# It is set dynamically in gcode with this construct: +# SET_GCODE_VARIABLE MACRO=_MMU_CUT_TIP VARIABLE=output_park_pos VALUE=.. +# +[gcode_macro _MMU_CUT_TIP] +description: Cut filament by pressing the cutter on a pin with a horizontal movement + +# -------------------------- Internal Don't Touch ------------------------- +variable_output_park_pos: 0 # Dynamically set in this macro + +gcode: + {% set final_eject = params.FINAL_EJECT|default(0)|int %} + {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set pin_loc_x, pin_loc_y = vars.pin_loc_xy|map('float') %} + {% set pin_park_dist = vars['pin_park_dist']|float %} + {% set retract_length = vars['retract_length']|float %} + {% set simple_tip_forming = vars['simple_tip_forming']|default(true)|lower == 'true' %} + {% set blade_pos = vars['blade_pos']|float %} + {% set rip_length = vars['rip_length']|float %} + {% set pushback_length = vars['pushback_length']|float %} + {% set pushback_dwell_time = vars['pushback_dwell_time']|int %} + {% set extruder_move_speed = vars['extruder_move_speed']|float %} + {% set travel_speed = vars['travel_speed']|float %} + {% set restore_position = vars['restore_position']|default(true)|lower == 'true' %} + {% set extruder_park_pos = blade_pos + rip_length %} + {% set cutting_axis = vars['cutting_axis'] %} + + {% if cutting_axis == "x" %} + {% set pin_park_x_loc = pin_loc_x + pin_park_dist %} + {% set pin_park_y_loc = pin_loc_y %} + {% else %} + {% set pin_park_y_loc = pin_loc_y + pin_park_dist %} + {% set pin_park_x_loc = pin_loc_x %} + {% endif %} + + {% if "xy" not in printer.toolhead.homed_axes %} + MMU_LOG MSG="Automatically homing XY" + G28 X Y + _CUT_TIP_MOVE_IN_BOUNDS + {% endif %} + + SAVE_GCODE_STATE NAME=_MMU_CUT_TIP_state # Save after possible homing operation to prevent 0,0 being recorded + + G90 # Absolute positioning + M83 # Relative extrusion + G92 E0 + + # Step 1 - Calculate initial retract to save filament waste, repeat to allow some cooling + {% set effective_retract_length = retract_length - printer.mmu.extruder_filament_remaining - park_vars.retracted_length %} + {% if effective_retract_length > 0 %} + MMU_LOG MSG="Retracting filament {effective_retract_length|round(1)}mm prior to cut" + G1 E-{effective_retract_length} F{extruder_move_speed * 60} + {% if simple_tip_forming %} + G1 E{effective_retract_length / 2} F{extruder_move_speed * 60} + G1 E-{effective_retract_length / 2} F{extruder_move_speed * 60} + {% endif %} + {% endif %} + + # Step 2 - Perform the cut + _CUT_TIP_ADJUST_CURRENT + _CUT_TIP_MOVE_TO_CUTTER_PIN PIN_PARK_X_LOC={pin_park_x_loc} PIN_PARK_Y_LOC={pin_park_y_loc} + _CUT_TIP_GANTRY_SERVO_DOWN + _CUT_TIP_DO_CUT_MOTION PIN_PARK_X_LOC={pin_park_x_loc} PIN_PARK_Y_LOC={pin_park_y_loc} RIP_LENGTH={rip_length} + _CUT_TIP_GANTRY_SERVO_UP + _CUT_TIP_RESTORE_CURRENT + _MMU_EVENT EVENT="filament_cut" + + # Step 3 - Pushback of the tip residual into the hotend to avoid future catching (ideally past the PTFE/metal boundary) + {% set effective_pushback_length = [pushback_length, retract_length - printer.mmu.extruder_filament_remaining - park_vars.retracted_length]|min %} + {% if effective_pushback_length > 0 %} + MMU_LOG MSG="Pushing filament fragment back {effective_pushback_length|round(1)}mm after cut" + G1 E{effective_pushback_length} F{extruder_move_speed * 60} + G4 P{pushback_dwell_time} + G1 E-{effective_pushback_length} F{extruder_move_speed * 60} + {% endif %} + + # Final eject is for testing + {% if final_eject %} + G92 E0 + G1 E-80 F{extruder_move_speed * 60} + {% endif %} + + # Dynamically set the required output variables for Happy Hare + SET_GCODE_VARIABLE MACRO=_MMU_CUT_TIP VARIABLE=output_park_pos VALUE={extruder_park_pos} + + # Restore state and optionally position (usually on wipetower) + RESTORE_GCODE_STATE NAME=_MMU_CUT_TIP_state MOVE={1 if restore_position else 0} MOVE_SPEED={travel_speed} + +########################################################################### +# Helper macro to alter X/Y stepper current during cut operation +# +[gcode_macro _CUT_TIP_ADJUST_CURRENT] +description: Helper to optionally increase X/Y stepper current prior to cut +variable_current_map: {} # Internal, don't set +gcode: + {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} + {% set cut_axis_steppers = (vars['cut_axis_steppers'] | default('')).split(',') | map('trim') | list %} + {% set cut_stepper_current = vars['cut_stepper_current'] | default(100) | int %} + {% set tmc_types = ["tmc2209", "tmc2130", "tmc2208", "tmc2660", "tmc5160", "tmc2240"] %} + + {% if not current_map %} + {% set ns = namespace(run_current={}) %} + {% if cut_stepper_current != 100 %} + {% for stepper in cut_axis_steppers %} + {% for tmc in tmc_types %} + {% set fullname = tmc ~ ' ' ~ stepper %} + {% if printer[fullname] is defined %} + # Save original run_current and reset to new value + {% set cur_rc = printer[fullname].run_current %} + {% if ns.run_current.update({stepper: cur_rc}) %}{% endif %} + {% set new_rc = cur_rc * cut_stepper_current | float / 100 %} + MMU_LOG MSG="Adjusting {stepper} current" + SET_TMC_CURRENT STEPPER={stepper} CURRENT={new_rc} + {% endif %} + {% endfor %} + {% endfor %} + {% endif %} + + SET_GCODE_VARIABLE MACRO=_CUT_TIP_ADJUST_CURRENT VARIABLE=current_map VALUE="{ns.run_current}" + {% endif %} + +########################################################################### +# Helper macro to restore X/Y stepper current after cutting action +# +[gcode_macro _CUT_TIP_RESTORE_CURRENT] +description: Helper to restore X/Y stepper current after cutting action +gcode: + {% set current_vars = printer['gcode_macro _CUT_TIP_ADJUST_CURRENT'] %} + {% set current_map = current_vars['current_map'] %} + + {% if current_map %} + {% for stepper, current in current_map.items() %} + MMU_LOG MSG="Restoring {stepper} current" + SET_TMC_CURRENT STEPPER={stepper} CURRENT={current} + {% endfor %} + {% endif %} + + SET_GCODE_VARIABLE MACRO=_CUT_TIP_ADJUST_CURRENT VARIABLE=current_map VALUE="{{}}" + + +########################################################################### +# Helper macro to ensure toolhead is in bounds after home in case it is +# used as a restore position point +# +[gcode_macro _CUT_TIP_MOVE_IN_BOUNDS] +description: Helper to move the toolhead to a legal position after homing +gcode: + {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} + {% set travel_speed = vars['travel_speed']|float %} + + {% set pos = printer.gcode_move.gcode_position %} + {% set axis_minimum = printer.toolhead.axis_minimum %} + {% set axis_maximum = printer.toolhead.axis_maximum %} + {% set x = [axis_minimum.x, [axis_maximum.x, pos.x]|min]|max %} + {% set y = [axis_minimum.y, [axis_maximum.y, pos.y]|min]|max %} + + MMU_LOG MSG="Warning: Klipper reported out of range gcode position (x:{pos.x}, y:{pos.y})! Adjusted to (x:{x}, y:{y}) to prevent move failure" ERROR=1 + G1 X{x} Y{y} F{travel_speed * 60} + + +########################################################################### +# Helper macro for tip cutting +# +[gcode_macro _CUT_TIP_MOVE_TO_CUTTER_PIN] +description: Helper to move the toolhead to the target pin in either safe or faster way, depending on toolhead clearance +gcode: + {% set pin_park_x_loc = params.PIN_PARK_X_LOC|float %} + {% set pin_park_y_loc = params.PIN_PARK_Y_LOC|float %} + {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} + + {% set safe_margin_x, safe_margin_y = vars.safe_margin_xy|map('float') %} + {% set travel_speed = vars['travel_speed']|float %} + {% set cutting_axis = vars['cutting_axis'] %} + + {% if ((printer.gcode_move.gcode_position.x - pin_park_x_loc)|abs < safe_margin_x) or ((printer.gcode_move.gcode_position.y - pin_park_y_loc)|abs < safe_margin_y) %} + # Make a safe but slower travel move + {% if cutting_axis == "x" %} + G1 X{pin_park_x_loc} F{travel_speed * 60} + G1 Y{pin_park_y_loc} F{travel_speed * 60} + {% else %} + G1 Y{pin_park_y_loc} F{travel_speed * 60} + G1 X{pin_park_x_loc} F{travel_speed * 60} + {% endif %} + {% else %} + G1 X{pin_park_x_loc} Y{pin_park_y_loc} F{travel_speed * 60} + {% endif %} + + +########################################################################### +# Helper macro for tip cutting +# +[gcode_macro _CUT_TIP_DO_CUT_MOTION] +description: Helper to do a single cut movement +gcode: + {% set pin_park_x_loc = params.PIN_PARK_X_LOC | float %} + {% set pin_park_y_loc = params.PIN_PARK_Y_LOC | float %} + {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} + {% set cutting_axis = vars['cutting_axis'] %} + + {% set pin_loc = vars['pin_loc_compressed']|default(-999)|float %} + {% if pin_loc != -999 %} + # Old one-dimensional pin_loc_compressed config + {% if cutting_axis == "x" %} + {% set pin_loc_compressed_x = pin_loc %} + {% set pin_loc_compressed_y = pin_park_y_loc %} + {% else %} + {% set pin_loc_compressed_x = pin_park_x_loc %} + {% set pin_loc_compressed_y = pin_loc %} + {% endif %} + {% else %} + # New config + {% set pin_loc_compressed_x, pin_loc_compressed_y = vars.pin_loc_compressed_xy|map('float') %} + {% endif %} + + {% set cut_fast_move_fraction = vars['cut_fast_move_fraction']|float %} + {% set cut_fast_move_speed = vars['cut_fast_move_speed']|float %} + {% set cut_slow_move_speed = vars['cut_slow_move_speed']|float %} + {% set cut_dwell_time = vars['cut_dwell_time']|float %} + {% set evacuate_speed = vars['evacuate_speed']|float %} + {% set rip_length = vars['rip_length']|float %} + {% set rip_speed = vars['rip_speed']|float %} + + + {% set fast_slow_transition_loc_x = (pin_loc_compressed_x - pin_park_x_loc) * cut_fast_move_fraction + pin_park_x_loc|float %} + {% set fast_slow_transition_loc_y = (pin_loc_compressed_y - pin_park_y_loc) * cut_fast_move_fraction + pin_park_y_loc|float %} + G1 X{fast_slow_transition_loc_x} Y{fast_slow_transition_loc_y} F{cut_fast_move_speed * 60} # Fast move to initiate contact of the blade with filament + G1 X{pin_loc_compressed_x} Y{pin_loc_compressed_y} F{cut_slow_move_speed * 60} # Do the cut in slow move + + G4 P{cut_dwell_time} + {% if rip_length > 0 %} + G1 E-{rip_length} F{rip_speed * 60} + {% endif %} + + G1 X{pin_park_x_loc} Y{pin_park_y_loc} F{evacuate_speed * 60} # Evacuate + + +########################################################################### +# Helper macro for tip cutting +# +[gcode_macro _CUT_TIP_GANTRY_SERVO_DOWN] +description: Operate optional gantry servo operated pin +gcode: + {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} + {% set gantry_servo_enabled = vars['gantry_servo_enabled']|default(true)|lower == 'true' %} + {% set angle = vars['gantry_servo_down_angle']|float %} + + {% if gantry_servo_enabled %} + SET_SERVO SERVO=mmu_gantry_servo ANGLE={angle} + G4 P500 # Pause to ensure servo is fully down before movement + {% endif %} + + +########################################################################### +# Helper macro for tip cutting +# +[gcode_macro _CUT_TIP_GANTRY_SERVO_UP] +description: Operate optional gantry servo operated pin +gcode: + {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} + {% set gantry_servo_enabled = vars['gantry_servo_enabled']|default(true)|lower == 'true' %} + {% set angle = vars['gantry_servo_up_angle']|float %} + + {% if gantry_servo_enabled %} + SET_SERVO SERVO=mmu_gantry_servo ANGLE={angle} DURATION=0.5 + {% endif %} diff --git a/config/base/mmu_form_tip.cfg b/config/base/mmu_form_tip.cfg new file mode 100644 index 000000000000..090c3721d3c8 --- /dev/null +++ b/config/base/mmu_form_tip.cfg @@ -0,0 +1,178 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Standalone Tip Forming roughly based on Superslicer +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# To configure, set +# 'form_tip_macro: _MMU_FORM_TIP' in 'mmu_parameters.cfg' +# +# This macro is, by default, called by Happy Hare to form filament tip +# prior to unloading. This will need to be tuned for your particular +# setup. Although the slicer can also perform similarly you must also +# tune tips here. The slicer will be used when printing, this logic will be +# used when not in print. Because of the need to setup twice, it is recommended +# that you turn off slicer tip forming and to use this routine in all circumstances. +# +# To force Happy Hare to always run this when loading filament add: +# 'force_form_tip_standalone: 1' in 'mmu_parameters.cfg' +# +# Also decide on whether you want toolhead to remain over wipetower while tool +# changing or move to park location (see 'enable_park' in mmu_sequence.cfg) +# +[gcode_macro _MMU_FORM_TIP] +description: Standalone macro that mimics Superslicer process + +gcode: + {% set final_eject = params.FINAL_EJECT|default(0)|int %} + {% set vars = printer['gcode_macro _MMU_FORM_TIP_VARS'] %} + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set unloading_speed_start = vars['unloading_speed_start']|int %} + {% set unloading_speed = vars['unloading_speed']|int %} + {% set ramming_volume = vars['ramming_volume']|float %} + {% set ramming_volume_standalone = vars['ramming_volume_standalone']|float %} + {% set cooling_tube_length = vars['cooling_tube_length']|float %} + {% set cooling_tube_position = vars['cooling_tube_position']|float %} + {% set initial_cooling_speed = vars['initial_cooling_speed']|int %} + {% set final_cooling_speed = vars['final_cooling_speed']|int %} + {% set cooling_moves = vars['cooling_moves']|int %} + {% set toolchange_temp = vars['toolchange_temp']|default(0)|int %} + {% set use_skinnydip = vars['use_skinnydip']|default(false)|lower == 'true' %} + {% set use_fast_skinnydip = vars['use_fast_skinnydip']|default(false)|lower == 'true' %} + {% set skinnydip_distance = vars['skinnydip_distance']|float %} + {% set dip_insertion_speed = vars['dip_insertion_speed']|int %} + {% set dip_extraction_speed = vars['dip_extraction_speed']|int %} + {% set melt_zone_pause = vars['melt_zone_pause']|default(0)|int %} + {% set cooling_zone_pause = vars['cooling_zone_pause']|default(0)|int %} + {% set extruder_eject_speed = vars['extruder_eject_speed']|int %} + {% set parking_distance = vars['parking_distance']|default(0)|float %} + {% set orig_temp = printer.extruder.target %} + {% set next_temp = params.NEXT_TEMP|default(orig_temp)|int %} + + # Useful state for customizing operations depending on mode + {% set runout = printer.mmu.runout %} + {% set printing = printer.mmu.print_state == 'printing' %} + + SAVE_GCODE_STATE NAME=MMU_FORM_TIP_state + + G91 # Relative positioning + M83 # Relative extrusion + G92 E0 + + # Step 1 - Ramming + # This is very generic and unlike slicer does not incorporate moves on the wipetower + {% set ramming_volume = ramming_volume_standalone if not printing else ramming_volume %} + {% if ramming_volume > 0 %} # Standalone Ramming + {% set ratio = ramming_volume / 23.0 %} + G1 E{0.5784 * ratio} F299 #7 + G1 E{0.5834 * ratio} F302 #3 + G1 E{0.5918 * ratio} F306 #6 + G1 E{0.6169 * ratio} F319 #6 + G1 E{0.3393 * ratio} F350 #0 + G1 E{0.3363 * ratio} F350 #0 + G1 E{0.7577 * ratio} F392 #6 + G1 E{0.8382 * ratio} F434 #3 + G1 E{0.7776 * ratio} F469 #9 + G1 E{0.1293 * ratio} F469 #9 + G1 E{0.9673 * ratio} F501 #2 + G1 E{1.0176 * ratio} F527 #2 + G1 E{0.5956 * ratio} F544 #6 + G1 E{0.4555 * ratio} F544 #6 + G1 E{1.0662 * ratio} F552 #4 + {% endif %} + + # Step 2 - Retraction / Nozzle Separation + # This is where the tip spear shape comes from. Faster=longer/pointer/higher stringing + {% set total_retraction_distance = cooling_tube_position - printer.mmu.extruder_filament_remaining - park_vars.retracted_length + cooling_tube_length - 15 %} + G1 E-15 F{1.0 * unloading_speed_start * 60} # Fixed default value from SS + {% if total_retraction_distance > 0 %} + G1 E-{(0.7 * total_retraction_distance)|round(2)} F{1.0 * unloading_speed * 60} + G1 E-{(0.2 * total_retraction_distance)|round(2)} F{0.5 * unloading_speed * 60} + G1 E-{(0.1 * total_retraction_distance)|round(2)} F{0.3 * unloading_speed * 60} + {% endif %} + + # Set toolchange temperature just prior to cooling moves (not fast skinnydip mode) + {% if toolchange_temp > 0 %} + M104 S{toolchange_temp} + {% if not use_fast_skinnydip %} + _WAIT_FOR_TEMP + {% endif %} + {% endif %} + + # Step 3 - Cooling Moves + # Solidifies tip shape and helps break strings if formed + {% set speed_inc = (final_cooling_speed - initial_cooling_speed) / (2 * cooling_moves - 1) %} + {% for move in range(cooling_moves) %} + {% set speed = initial_cooling_speed + speed_inc * move * 2 %} + G1 E{cooling_tube_length} F{speed * 60} + G1 E-{cooling_tube_length} F{(speed + speed_inc) * 60} + {% endfor %} + + # Wait for extruder to reach toolchange temperature after cooling moves complete (fast skinnydip only) + {% if toolchange_temp > 0 and use_skinnydip and use_fast_skinnydip %} + _WAIT_FOR_TEMP + {% endif %} + + # Step 4 - Skinnydip + # Burns off very fine hairs (Good for PLA) + {% if use_skinnydip %} + G1 E{skinnydip_distance} F{dip_insertion_speed * 60} + G4 P{melt_zone_pause} + G1 E-{skinnydip_distance} F{dip_extraction_speed * 60} + G4 P{cooling_zone_pause} + {% endif %} + + # Set temperature target to next filament temp or starting temp. Note that we don't + # wait because the temp will settle during the rest of the toolchange + M104 S{next_temp} + + # Step 5 - Parking + # Optional park filament at fixed location or eject completely (testing) + {% if final_eject %} + G92 E0 + G1 E-80 F{extruder_eject_speed * 60} + {% elif parking_distance > 0 %} + G90 # Absolute positioning + M82 # Absolute extrusion + G1 E-{parking_distance} F{extruder_eject_speed * 60} + {% endif %} + + # Restore state + RESTORE_GCODE_STATE NAME=MMU_FORM_TIP_state + + +[gcode_macro _WAIT_FOR_TEMP] +description: Helper function for fan assisted extruder temp reduction +gcode: + {% set vars = printer['gcode_macro _MMU_FORM_TIP_VARS'] %} + {% set toolchange_temp = vars['toolchange_temp']|default(0)|int %} + {% set toolchange_use_fan = vars['toolchange_fan_assist']|default(false)|lower == 'true' %} + {% set toolchange_fan_speed = vars['toolchange_fan_speed']|default(50)|int %} + {% set toolchange_fan = vars['toolchange_fan_name']|default('')|string %} + + MMU_LOG MSG='{"Waiting for extruder temp %d\u00B0C..." % toolchange_temp}' + {% if toolchange_use_fan %} + {% if printer.fan is defined or printer[toolchange_fan] is defined %} + {% set orig_fan_speed = printer[toolchange_fan].speed if printer[toolchange_fan] is defined else printer.fan.speed %} + M106 S{(toolchange_fan_speed / 100 * 255)|int} + M109 S{toolchange_temp} + M106 S{(orig_fan_speed * 255)|int} + {% else %} + MMU_LOG MSG="Warning: Printer part fan is not defined. Ignoring 'toolchange_use_fan' option" ERROR=1 + M109 S{toolchange_temp} + {% endif %} + {% else %} + M109 S{toolchange_temp} + {% endif %} + diff --git a/config/base/mmu_hardware.cfg b/config/base/mmu_hardware.cfg new file mode 100644 index 000000000000..6982e59b9ccc --- /dev/null +++ b/config/base/mmu_hardware.cfg @@ -0,0 +1,485 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Happy Hare MMU hardware config file with config for {brd_type} MCU board +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# Notes about setup of common external MCUs can be found here: +# https://github.com/moggieuk/Happy-Hare/blob/main/doc/mcu_notes.md +# +# Note about "touch" endstops: Happy Hare provides extremely flexible homing options using both single steppers or +# synced steppers. The "touch" option leverages stallguard and thus requires the appropriate 'diag_pin' and stallguard +# parameters set on the TMC driver section. If you have the diag_pin exposed, it is harmless to define this because +# they will only be used when explicitly needed and configured. +# +# Touch option for each stepper provides these benefits / possibilities (experimental): +# - on extruder stepper allows for the automatic detection of the nozzle! +# - on selector stepper allows for the automatic detection of filament stuck in the gate and subsequent recovery +# - on gear stepper allows for the automatic detection of the extruder entrance +# +# In summary, "touch" homing with your MMU is an advanced option that requires patience and careful tuning. Everything +# works with regular endstops and there are workaround options for certain homing points (like extruder entry) in +# the absence of any endstop. I'm really interested in creative setups. Ping me on Discord (moggieuk#6538) +# +# See 'mmu.cfg' for serial definition and pins aliases +# +# HOMING CAPABLE EXTRUDER (VERY ADVANCED) ----------------------------------------------------------------------------- +# With Happy Hare installed even the extruder can be homed. You will find the usual 'endstop' parameters can be added +# to your '[extruder]' section. Useless you have some clever load cell attached to your nozzle it only really makes +# sense to configure stallguard style "touch" homing. To do this add lines similar to this to your existing +# '[extruder]' definition in printer.cfg. +# +# [extruder] +# endstop_pin: tmc2209_extruder:virtual_endstop +# +# Also be sure to add the appropriate stallguard config to the TMC section, e.g. +# +# [tmc2209 extruder] +# diag_pin: E_DIAG # Set to MCU pin connected to TMC DIAG pin for extruder +# driver_SGTHRS: 100 # 255 is most sensitive value, 0 is least sensitive +# +# Happy Hare will take care of the rest and add a 'mmu_ext_touch' endstop automatically +# + + +# MMU MACHINE / TYPE --------------------------------------------------------------------------------------------------- +# ███╗ ███╗███╗ ███╗██╗ ██╗ ███╗ ███╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗███████╗ +# ████╗ ████║████╗ ████║██║ ██║ ████╗ ████║██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝ +# ██╔████╔██║██╔████╔██║██║ ██║ ██╔████╔██║███████║██║ ███████║██║██╔██╗ ██║█████╗ +# ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ ██║╚██╔╝██║██╔══██║██║ ██╔══██║██║██║╚██╗██║██╔══╝ +# ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║██║██║ ╚████║███████╗ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ +[mmu_machine] + +# Number of selectable gate on (each) MMU. Generally this is a single number, but with multi-mmu (type-B) setups +# it can be a comma separated list of the number of gates per unit. +# E.g. 'num_gates: 4,4,2' for a 2xBox Turtle and 1xNight Owl multiplexed setup +# +num_gates: {num_gates} + +# MMU Vendor & Version is used to automatically configure some parameters and validate configuration +# If custom set to "Other" and uncomment the additional parameters below +# +# ERCF 1.1 add "s" suffix for Springy, "b" for Binky, "t" for Triple-Decky +# e.g. "1.1sb" for v1.1 with Springy mod and Binky encoder +# ERCF 2.0 community edition ERCFv2 +# ERCF 3.0 community edition ERCFv3 +# Tradrack 1.0 add "e" if encoder is fitted (assumed to be Binky) +# AngryBeaver +# BoxTurtle +# NightOwl +# 3MS +# QuattroBox 1.0 +# QuattroBox 2.0 revised LEDs +# 3D Chameleon +# Pico +# MMX +# BTT ViViD +# KMS +# EMU +# Prusa 3.0 NOT YET SUPPORTED - COMING SOON +# Other Generic setup that may require further customization of 'cad' parameters. See doc in mmu_parameters.cfg +# +mmu_vendor: {mmu_vendor} # MMU family +mmu_version: {mmu_version} # MMU hardware version number (add mod suffix documented above) + +# The following attributes are set internally from vendor/version above. Only uncomment to customize the vendor +# default or for custom ("Other") designs +# +#selector_type: {selector_type} # E.g. LinearServoSelector (type-A), VirtualSelector (type-B), MacroSelector, RotarySelector, ... +#variable_bowden_lengths: {variable_bowden_lengths} # 1 = If MMU design has different bowden lengths per gate, 0 = bowden length is the same +#variable_rotation_distances: {variable_rotation_distances} # 1 = If MMU design has dissimilar drive/BMG gears, thus rotation distance, 0 = One drive gear (e.g. Tradrack) +#require_bowden_move: {require_bowden_move} # 1 = If MMU design has bowden move that is included in load/unload, 0 = zero length bowden (skip bowden move) +#filament_always_gripped: {filament_always_gripped} # 1 = Filament is always trapped by MMU (most type-B designs), 0 = MMU can release filament +#has_bypass: {has_bypass} # 1 = Integrated bypass gate available, 0 = No integrated bypass; Bypassing only possible via PTFE + +homing_extruder: 1 # CAUTION: Normally this should be 1. 0 will disable the homing extruder capability + +# Uncomment to change the display name in UI's. Defaults to the vendor name +#display_name: My Precious + +# Full name of environment sensor object for MMU filament storage (displays temp and humidity in UI). +# Leave empty if sensor is not fitted. Polls for "temperature" and "humidity" +# E.g. If you have a section like this: [temperature_sensor MMU_enclosure] +# Set: environment_sensor: temperature_sensor MMU_enclosure +environment_sensor: + +# Full name of filament heater object. Leave empty if heater is not fitted +# E.g. If you have a section like this: [heater_generic MMU_heater] +# Set: filament_heater: heater_generic MMU_heater +filament_heater: + +# PER-GATE heaters and environment sensors. Some modular MMU designs have per-gate control (e.g. EMU). For this type of MMU, leave +# 'environment_sensor' and 'filament_heater' empty and define a list of heaters and sensors here. Note the length of the list must +# match the number of gates. Leave this settings empty if defining a single sensor/heater. +environment_sensors: # Comma separated list of full environment sensor object names +filament_heaters: # Comma separated list of full heater object names +max_concurrent_heaters: 1 # Limit the number of simultaneously active heaters (power/PSU protection) + + +# FILAMENT DRIVE GEAR STEPPER(S) -------------------------------------------------------------------------------------- +# ██████╗ ███████╗ █████╗ ██████╗ +# ██╔════╝ ██╔════╝██╔══██╗██╔══██╗ +# ██║ ███╗█████╗ ███████║██████╔╝ +# ██║ ██║██╔══╝ ██╔══██║██╔══██╗ +# ╚██████╔╝███████╗██║ ██║██║ ██║ +# ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ +# Note that 'toolhead' & 'mmu_gear' endstops will automatically be added if a toolhead sensor or gate sensor is defined +# +# The default values are tested with the ERCF BOM NEMA14 motor. Please adapt these values to the motor you are using +# Example : for NEMA17 motors, you'll usually use higher current +# +[tmc2209 stepper_mmu_gear] +uart_pin: mmu:MMU_GEAR_UART +uart_address: 0 # Only for old EASY-BRD mcu +run_current: {gear_run_current} # ERCF BOM NEMA14 motor +hold_current: {gear_hold_current} # Recommend to be small if not using "touch" or move (TMC stallguard) +interpolate: True +sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 +stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed +# +# Uncomment two lines below if you have TMC and want the ability to use filament "touch" homing with gear stepper +#diag_pin: ^mmu:MMU_GEAR_DIAG # Set to MCU pin connected to TMC DIAG pin for gear stepper +#driver_SGTHRS: 60 # 255 is most sensitive value, 0 is least sensitive + +[stepper_mmu_gear] +step_pin: mmu:MMU_GEAR_STEP +dir_pin: !mmu:MMU_GEAR_DIR +enable_pin: !mmu:MMU_GEAR_ENABLE +rotation_distance: 22.7316868 # Bondtech 5mm Drive Gears. Overridden by 'mmu_gear_rotation_distance' in mmu_vars.cfg +gear_ratio: {gear_gear_ratio} # E.g. ERCF 80:20, Tradrack 50:17 +microsteps: 16 # Recommend 16. Increase only if you "step compress" issues when syncing +full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree +# +# Uncomment the two lines below to enable filament "touch" homing option with gear motor +#extra_endstop_pins: tmc2209_stepper_mmu_gear:virtual_endstop +#extra_endstop_names: mmu_gear_touch + +# ADDITIONAL FILAMENT DRIVE GEAR STEPPERS FOR TYPE-B MMU's ------------------------------------------------------------- +# Note that common parameters are inherited from base stepper_mmu_gear, but can be uniquely specified here too +# +# Filament Drive Gear_1 -------------------------- +[tmc2209 stepper_mmu_gear_1] +uart_pin: mmu:MMU_GEAR_UART_1 + +[stepper_mmu_gear_1] +step_pin: mmu:MMU_GEAR_STEP_1 +dir_pin: !mmu:MMU_GEAR_DIR_1 +enable_pin: !mmu:MMU_GEAR_ENABLE_1 + + +# SELECTOR STEPPER ---------------------------------------------------------------------------------------------------- +# ███████╗███████╗██╗ ███████╗ ██████╗████████╗ ██████╗ ██████╗ +# ██╔════╝██╔════╝██║ ██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ +# ███████╗█████╗ ██║ █████╗ ██║ ██║ ██║ ██║██████╔╝ +# ╚════██║██╔══╝ ██║ ██╔══╝ ██║ ██║ ██║ ██║██╔══██╗ +# ███████║███████╗███████╗███████╗╚██████╗ ██║ ╚██████╔╝██║ ██║ +# ╚══════╝╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ +# Consult doc if you want to setup selector for "touch" homing instead or physical endstop +# +[tmc2209 stepper_mmu_selector] +uart_pin: mmu:MMU_SEL_UART +uart_address: 1 # Only for EASY-BRD +run_current: {sel_run_current} # ERCF BOM NEMA17 motor +hold_current: {sel_hold_current} # Can be small if not using "touch" movement (TMC stallguard) +interpolate: True +sense_resistor: 0.110 +stealthchop_threshold: 100 # Stallguard "touch" movement (slower speeds) best done with stealthchop +# +# Uncomment two lines below if you have TMC and want to use selector "touch" movement +#diag_pin: ^mmu:MMU_SEL_DIAG # Set to MCU pin connected to TMC DIAG pin for selector stepper +#driver_SGTHRS: 75 # 255 is most sensitive value, 0 is least sensitive + +[stepper_mmu_selector] +step_pin: mmu:MMU_SEL_STEP +dir_pin: !mmu:MMU_SEL_DIR +enable_pin: !mmu:MMU_SEL_ENABLE +rotation_distance: 40 +microsteps: 16 # Don't need high fidelity +gear_ratio: {sel_gear_ratio} +full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree +endstop_pin: ^mmu:MMU_SEL_ENDSTOP # Selector microswitch +endstop_name: mmu_sel_home +# Uncomment this line only if default endstop above is using stallguard +#homing_retract_dist: 0 +# +# Uncomment two lines below to give option of selector "touch" movement +#extra_endstop_pins: tmc2209_stepper_mmu_selector:virtual_endstop +#extra_endstop_names: mmu_sel_touch + + +# SERVOS --------------------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ ███████╗ +# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗██╔════╝ +# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║███████╗ +# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║╚════██║ +# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝███████║ +# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ +# Basic servo PWM setup. If these values are changed then the angles defined for different positions will also change +# +# SELECTOR SERVO ------------------------------------------------------------------------------------------------------- +# +[mmu_servo selector_servo] +pin: mmu:MMU_SEL_SERVO +maximum_servo_angle: {maximum_servo_angle} +minimum_pulse_width: {minimum_pulse_width} +maximum_pulse_width: {maximum_pulse_width} +# +# OPTIONAL GANTRY SERVO FOR TOOLHEAD FILAMENT CUTTER ------------------------------------------------------------------ +# +# (uncomment this section if you have a gantry servo for toolhead cutter pin) +#[mmu_servo mmu_gantry_servo] +#pin: {gantry_servo_pin} +#maximum_servo_angle:180 +#minimum_pulse_width: 0.00075 +#maximum_pulse_width: 0.00225 +#initial_angle: 180 + + +# FILAMENT SENSORS ----------------------------------------------------------------------------------------------------- +# ███████╗███████╗███╗ ██╗███████╗ ██████╗ ██████╗ ███████╗ +# ██╔════╝██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ +# ███████╗█████╗ ██╔██╗ ██║███████╗██║ ██║██████╔╝███████╗ +# ╚════██║██╔══╝ ██║╚██╗██║╚════██║██║ ██║██╔══██╗╚════██║ +# ███████║███████╗██║ ╚████║███████║╚██████╔╝██║ ██║███████║ +# ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ +# Define the pins for optional sensors in the filament path. All but the pre-gate sensors will be automatically setup as +# both endstops (for homing) and sensors for visibility purposes. +# +# 'pre_gate_switch_pin_X' .. 'mmu_pre_gate_X' sensor detects filament at entry to MMU. X=gate number (0..N) +# 'gate_switch_pin' .. 'mmu_gate' shared sensor detects filament past the gate of the MMU +# or +# 'post_gear_switch_pin_X' .. 'mmu_gear_X' post gear sensor for each filament +# 'extruder_switch_pin' .. 'extruder' sensor detects filament just before the extruder entry +# 'toolhead_switch_pin' .. 'toolhead' sensor detects filament after extruder entry +# +# Sync motor feedback will typically have a tension switch (most important for syncing) or both tension and compression. +# Note that compression switch is useful for use as a endstop to detect hitting the extruder entrance +# 'sync_feedback_tension_pin' .. pin for switch activated when filament is under tension +# 'sync_feedback_compression_pin' .. pin for switch activated when filament is under compression +# +# Configuration is flexible: Simply define pins for any sensor you want to enable, if pin is not set (or the alias is empty) +# it will be ignored. You can also just comment out what you are not using. +# +[mmu_sensors] +pre_gate_switch_pin_0: ^mmu:MMU_PRE_GATE_0 +pre_gate_switch_pin_1: ^mmu:MMU_PRE_GATE_1 +pre_gate_switch_pin_2: ^mmu:MMU_PRE_GATE_2 +pre_gate_switch_pin_3: ^mmu:MMU_PRE_GATE_3 +pre_gate_switch_pin_4: ^mmu:MMU_PRE_GATE_4 +pre_gate_switch_pin_5: ^mmu:MMU_PRE_GATE_5 +pre_gate_switch_pin_6: ^mmu:MMU_PRE_GATE_6 +pre_gate_switch_pin_7: ^mmu:MMU_PRE_GATE_7 +pre_gate_switch_pin_8: ^mmu:MMU_PRE_GATE_8 +pre_gate_switch_pin_9: ^mmu:MMU_PRE_GATE_9 +pre_gate_switch_pin_10: ^mmu:MMU_PRE_GATE_10 +pre_gate_switch_pin_11: ^mmu:MMU_PRE_GATE_11 + +post_gear_switch_pin_0: ^mmu:MMU_POST_GEAR_0 +post_gear_switch_pin_1: ^mmu:MMU_POST_GEAR_1 +post_gear_switch_pin_2: ^mmu:MMU_POST_GEAR_2 +post_gear_switch_pin_3: ^mmu:MMU_POST_GEAR_3 +post_gear_switch_pin_4: ^mmu:MMU_POST_GEAR_4 +post_gear_switch_pin_5: ^mmu:MMU_POST_GEAR_5 +post_gear_switch_pin_6: ^mmu:MMU_POST_GEAR_6 +post_gear_switch_pin_7: ^mmu:MMU_POST_GEAR_7 +post_gear_switch_pin_8: ^mmu:MMU_POST_GEAR_8 +post_gear_switch_pin_9: ^mmu:MMU_POST_GEAR_9 +post_gear_switch_pin_10: ^mmu:MMU_POST_GEAR_10 +post_gear_switch_pin_11: ^mmu:MMU_POST_GEAR_11 + +# These sensors can be replicated in a multi-mmu, type-B setup (see num_gates comment). +# If so, then use a comma separated list of per-unit pins instead of single pin +# +gate_switch_pin: ^mmu:MMU_GATE_SENSOR +sync_feedback_tension_pin: {sync_feedback_tension_pin} +sync_feedback_compression_pin: {sync_feedback_compression_pin} + +# Proportional sync feedback sensor configuration. Leave empty if not fitted. +# (if you have a proportional sensor the sync_feedback_tension_pin and sync_feedback_compression_pin would likely be empty) +# +sync_feedback_analog_pin: # The ADC pin where the proportional filament pressure sensor is installed +sync_feedback_analog_max_compression: 1 # Raw sensor reading at max filament compression (buffer squeezed) +sync_feedback_analog_max_tension: 0 # Raw sensor reading at max filament tension (buffer expanded) +sync_feedback_analog_neutral_point: 0.50 # Biasing of neutral point (sensor value 0). Normally close to 0.5 + +# These sensors are on the toolhead and often controlled by the main printer mcu +# +extruder_switch_pin: {extruder_sensor_pin} +toolhead_switch_pin: {toolhead_sensor_pin} + + +# ENCODER ------------------------------------------------------------------------------------------------------------- +# ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ +# ██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗ +# █████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗ ██████╔╝ +# ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗ +# ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗██║ ██║ +# ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ +# Encoder measures distance, monitors for runout and clogging and constantly calculates % flow rate +# Note that the encoder_resolution set here is purely a default to get started. It will be correcly set after calibration +# with the value stored in mmu_vars.cfg +# +# The encoder resolution will be calibrated but it needs a default approximation +# If BMG gear based: +# resolution = bmg_circumfrance / (2 * teeth) +# 24 / (2 * 17) = 0.7059 for TRCT5000 based sensor +# 24 / (2 * 12) = 1.0 for Binky with 12 tooth disc +# +[mmu_encoder mmu_encoder] +encoder_pin: ^mmu:MMU_ENCODER +encoder_resolution: {encoder_resolution} # This is just a starter value. Overriden by calibrated 'mmu_encoder_resolution' in mmm_vars.cfg +desired_headroom: 5.0 # The clog/runout headroom that MMU attempts to maintain (closest point to triggering runout) +average_samples: 4 # The "damping" effect of last measurement (higher value means slower automatic clog_length reduction) +flowrate_samples: 20 # How many "movements" of the extruder to measure average flowrate over + + +# ESPOOLER (OPTIONAL) ------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ +# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ +# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ +# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ +# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ +# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ +# +# An espooler controls DC motors (typically N20) that are able to rewind a filament spool and optionally provide +# forward assist to overcome spooler rotation friction. This should define pins for each of the gates on your mmu +# starting with '_0'. +# An empty pin can be deleted, commented or simply left blank. If you mcu has a separate "enable" pin +# +[mmu_espooler mmu_espooler] +pwm: 1 # 1=PWM control (typical), 0=digital on/off control +#hardware_pwm: 0 # See klipper doc +#cycle_time: 0.100 # See klipper doc +scale: 1 # Scales the PWM output range +#value: 0 # See klipper doc +#shutdown_value: 0 # See klipper doc + +respool_motor_pin_0: mmu:MMU_ESPOOLER_RWD_0 # PWM (or digital) pin for rewind/respool movement +assist_motor_pin_0: mmu:MMU_ESPOOLER_FWD_0 # PWM (or digital) pin for forward motor movement +enable_motor_pin_0: mmu:MMU_ESPOOLER_EN_0 # Digital output for Afc mcu +assist_trigger_pin_0: mmu:MMU_ESPOOLER_TRIG_0 # Trigger pin for sensing need to assist during print + +respool_motor_pin_1: mmu:MMU_ESPOOLER_RWD_1 +assist_motor_pin_1: mmu:MMU_ESPOOLER_FWD_1 +enable_motor_pin_1: mmu:MMU_ESPOOLER_EN_1 +assist_trigger_pin_1: mmu:MMU_ESPOOLER_TRIG_1 + +respool_motor_pin_2: mmu:MMU_ESPOOLER_RWD_2 +assist_motor_pin_2: mmu:MMU_ESPOOLER_FWD_2 +enable_motor_pin_2: mmu:MMU_ESPOOLER_EN_2 +assist_trigger_pin_2: mmu:MMU_ESPOOLER_TRIG_2 + +respool_motor_pin_3: mmu:MMU_ESPOOLER_RWD_3 +assist_motor_pin_3: mmu:MMU_ESPOOLER_FWD_3 +enable_motor_pin_3: mmu:MMU_ESPOOLER_EN_3 +assist_trigger_pin_3: mmu:MMU_ESPOOLER_TRIG_3 + + +# MMU OPTIONAL NEOPIXEL LED SUPPORT ------------------------------------------------------------------------------------ +# ██╗ ███████╗██████╗ ███████╗ +# ██║ ██╔════╝██╔══██╗██╔════╝ +# ██║ █████╗ ██║ ██║███████╗ +# ██║ ██╔══╝ ██║ ██║╚════██║ +# ███████╗███████╗██████╔╝███████║ +# ╚══════╝╚══════╝╚═════╝ ╚══════╝ +# Define the led connection, type and length +# +# (comment out this section if you don't have leds or have them defined elsewhere) +[neopixel mmu_leds] +pin: mmu:MMU_NEOPIXEL +chain_count: {chain_count} # Need number gates x1 or x2 + status leds +color_order: {color_order} # Set based on your particular neopixel specification (can be comma separated list) + +# MMU LED EFFECT SEGMENTS ---------------------------------------------------------------------------------------------- +# Define neopixel LEDs for your MMU. The chain_count must be large enough for your desired ranges: +# exit .. this set of LEDs, one for every gate, usually would be mounted at the exit point of the gate +# entry .. this set of LEDs, one for every gate, could be mounted at the entry point of filament into the MMU/buffer +# status .. these LED. represents the status of the MMU (and selected filament). More than one status LED is possible +# logo .. these LEDs don't change during operation and are designed for driving a logo. More than one logo LED is possible +# +# Note that all sets are optional. You can opt to just have the 'exit' set for example. The advantage to having +# both entry and exit LEDs is, for example, so that 'entry' can display gate status while 'exit' displays the color +# +# LED's are indexed in the chain from 1..N. Thus to set up LED's on 'exit' and a single 'status' LED on a 4 gate MMU: +# +# exit_leds: neopixel:mmu_leds (1,2,3,4) +# status_leds: neopixel:mmu_leds (5) +# +# In this example no 'entry' set is configured. Note that constructs like "mmu_leds (1-3,4)" are also valid +# +# The range is completely flexible and can be comprised of different led strips, individual LEDs, or combinations of +# both on different pins. In addition, the ordering is flexible based on your wiring, thus (1-4) and (4-1) both represent +# the same LED range but mapped to increasing or decreasing gates respectively. E.g if you have two Box Turtle MMUs, one +# with a chain of LEDs wired in reverse order and another with individual LEDs, to define 8 exit LEDs: +# +# exit_leds: neopixel:bt_1 (4-1) +# neopixel:bt_2a +# neopixel:bt_2b +# neopixel:bt_2c +# neopixel:bt_2d +# +# Note the use of separate lines for each part of the definition, +# +# ADVANCED: Happy Hare provides a convenience wrapper [mmu_led_effect] that not only creates an effect on each of the +# [mmu_leds] specified segments as a whole but also each individual LED for atomic control. See mmu_leds.cfg for examples +# +# (comment out this whole section if you don't have/want leds; uncomment/edit LEDs fitted on your MMU) +[mmu_leds] +exit_leds: {exit_leds} +entry_leds: {entry_leds} +status_leds: {status_leds} +logo_leds: {logo_leds} +frame_rate: 24 + +# Default effects for LED segments when not providing action status +# off - LED's off +# on - LED's white +# gate_status - indicate gate availability / status (printer.mmu.gate_status) +# filament_color - display filament color defined in gate map (printer.mmu.gate_color_rgb) +# slicer_color - display slicer defined set color for each gate (printer.mmu.slicer_color_rgb) +# (r,g,b) - display static r,g,b color e.g. "0,0,0.3" for dim blue +# _effect_ - display the named led effect +# +enabled: True # True = LEDs are enabled at startup (MMU_LED can control), False = Disabled +animation: True # True = Use led-animation-effects, False = Static LEDs +exit_effect: gate_status # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +entry_effect: filament_color # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +status_effect: filament_color # on|off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +logo_effect: (0, 0, 0.3) # off |r,g,b|_effect_ +white_light: (1, 1, 1) # RGB color for static white light +black_light: (.01, 0, .02) # RGB color used to represent "black" (filament) +empty_light: (0, 0, 0) # RGB color used to represent empty gate + +# Default effects (animation: True) / static rbg (animation False) to apply to actions +# effect_name, (r,b,g) +# +# IMPORTANT: Effects must be from [mmu_led_effects] set defined in mmu_leds.cfg +# +effect_loading: mmu_blue_clockwise_slow, (0, 0, 0.4) +effect_loading_extruder: mmu_blue_clockwise_fast, (0, 0, 1) +effect_unloading: mmu_blue_anticlock_slow, (0, 0, 0.4) +effect_unloading_extruder: mmu_blue_anticlock_fast, (0, 0, 1) +effect_heating: mmu_breathing_red, (0.3, 0, 0) +effect_selecting: mmu_white_fast, (0.2, 0.2, 0.2) +effect_checking: mmu_white_fast, (0.8, 0.8, 0.8) +effect_initialized: mmu_rainbow, (0.5, 0.2, 0) +effect_error: mmu_strobe, (1, 0, 0) +effect_complete: mmu_sparkle, (0.3, 0.3, 0.3) +effect_gate_selected: mmu_static_blue, (0, 0, 1) +effect_gate_available: mmu_static_green, (0, 0.5, 0) +effect_gate_available_sel: mmu_ready_green, (0, 0.75, 0) +effect_gate_unknown: mmu_static_orange, (0.5, 0.2, 0) +effect_gate_unknown_sel: mmu_ready_orange, (0.75, 0.3, 0) +effect_gate_empty: mmu_static_black, (0, 0, 0) +effect_gate_empty_sel: mmu_ready_orange2, (0.1, 0.04, 0) diff --git a/config/base/mmu_hardware.cfg.kms b/config/base/mmu_hardware.cfg.kms new file mode 100644 index 000000000000..3ff5c1adb1ec --- /dev/null +++ b/config/base/mmu_hardware.cfg.kms @@ -0,0 +1,477 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Happy Hare MMU hardware config file with config for KMS MCU board +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# Notes about setup of common external MCUs can be found here: +# https://github.com/moggieuk/Happy-Hare/blob/main/doc/mcu_notes.md +# +# Note about "touch" endstops: Happy Hare provides extremely flexible homing options using both single steppers or +# synced steppers. The "touch" option leverages stallguard and thus requires the appropriate 'diag_pin' and stallguard +# parameters set on the TMC driver section. If you have the diag_pin exposed, it is harmless to define this because +# they will only be used when explicitly needed and configured. +# +# Touch option for each stepper provides these benefits / possibilities (experimental): +# - on extruder stepper allows for the automatic detection of the nozzle! +# - on selector stepper allows for the automatic detection of filament stuck in the gate and subsequent recovery +# - on gear stepper allows for the automatic detection of the extruder entrance +# +# In summary, "touch" homing with your MMU is an advanced option that requires patience and careful tuning. Everything +# works with regular endstops and there are workaround options for certain homing points (like extruder entry) in +# the absence of any endstop. I'm really interested in creative setups. Ping me on Discord (moggieuk#6538) +# +# See 'mmu.cfg' for serial definition and pins aliases +# +# HOMING CAPABLE EXTRUDER (VERY ADVANCED) ----------------------------------------------------------------------------- +# With Happy Hare installed even the extruder can be homed. You will find the usual 'endstop' parameters can be added +# to your '[extruder]' section. Useless you have some clever load cell attached to your nozzle it only really makes +# sense to configure stallguard style "touch" homing. To do this add lines similar to this to your existing +# '[extruder]' definition in printer.cfg. +# +# [extruder] +# endstop_pin: tmc2209_extruder:virtual_endstop +# +# Also be sure to add the appropriate stallguard config to the TMC section, e.g. +# +# [tmc2209 extruder] +# diag_pin: E_DIAG # Set to MCU pin connected to TMC DIAG pin for extruder +# driver_SGTHRS: 100 # 255 is most sensitive value, 0 is least sensitive +# +# Happy Hare will take care of the rest and add a 'mmu_ext_touch' endstop automatically +# + + +# MMU MACHINE / TYPE --------------------------------------------------------------------------------------------------- +# ███╗ ███╗███╗ ███╗██╗ ██╗ ███╗ ███╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗███████╗ +# ████╗ ████║████╗ ████║██║ ██║ ████╗ ████║██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝ +# ██╔████╔██║██╔████╔██║██║ ██║ ██╔████╔██║███████║██║ ███████║██║██╔██╗ ██║█████╗ +# ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ ██║╚██╔╝██║██╔══██║██║ ██╔══██║██║██║╚██╗██║██╔══╝ +# ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║██║██║ ╚████║███████╗ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ +[mmu_machine] + +# Number of selectable gate on (each) MMU. Generally this is a single number, but with multi-mmu (type-B) setups +# it can be a comma separated list of the number of gates per unit. +# E.g. 'num_gates: 4,4,2' for a 2xBox Turtle and 1xNight Owl multiplexed setup +# +num_gates: 4 + +# MMU Vendor & Version is used to automatically configure some parameters and validate configuration +# If custom set to "Other" and uncomment the additional parameters below +# +# ERCF 1.1 add "s" suffix for Springy, "b" for Binky, "t" for Triple-Decky +# e.g. "1.1sb" for v1.1 with Springy mod and Binky encoder +# ERCF 2.0 community edition ERCFv2 +# ERCF 2.5 +# Tradrack 1.0 add "e" if encoder is fitted (assumed to be Binky) +# AngryBeaver 1.0 +# BoxTurtle 1.0 +# NightOwl 1.0 +# 3MS 1.0 +# 3D Chameleon 1.0 +# Pico 1.0 +# Prusa 3.0 NOT YET SUPPORTED - COMING SOON +# Other Generic setup that may require further customization of 'cad' parameters. See doc in mmu_parameters.cfg +# +mmu_vendor: KMS # MMU family +mmu_version: 1.0 # MMU hardware version number (add mod suffix documented above) + +# The following attributes are set internally from vendor/version above. Only uncomment to customize the vendor +# default or for custom ("Other") designs +# +#selector_type: VirtualSelector # E.g. LinearSelector (type-A), VirtualSelector (type-B), MacroSelector, RotarySelector, ... +#variable_bowden_lengths: 0 # 1 = If MMU design has different bowden lengths per gate, 0 = bowden length is the same +#variable_rotation_distances: 1 # 1 = If MMU design has dissimilar drive/BMG gears, thus rotation distance, 0 = One drive gear (e.g. Tradrack) +#require_bowden_move: 1 # 1 = If MMU design has bowden move that is included in load/unload, 0 = zero length bowden (skip bowden move) +#filament_always_gripped: 1 # 1 = Filament is always trapped by MMU (most type-B designs), 0 = MMU can release filament +#has_bypass: {has_bypass} # 1 = Integrated bypass gate available, 0 = No integrated bypass; Bypassing only possible via PTFE + +homing_extruder: 1 # CAUTION: Normally this should be 1. 0 will disable the homing extruder capability + +# Uncomment to change the display name in UI's. Defaults to the vendor name +#display_name: My Precious + +# Fullname of environment sensor for MMU filament storage (displays temp and humidity in UI). Leave empty if sensor is not fitted +# Polls for "temperature" and "humidity" +# E.g. If you have a section like this: [temperature_sensor MMU_enclosure] +# Set: environment_sensor: temperature_sensor MMU_enclosure +environment_sensor: temperature_sensor MMU_enviroment_kms + +# Full name of filament heater object. Leave empty if heater is not fitted +# E.g. If you have a section like this: [heater_generic MMU_heater] +# Set: filament_heater: heater_generic MMU_heater +filament_heater: heater_generic MMU_heater_kms + +# PER-GATE heaters and environment sensors. Some modular MMU designs have per-gate control (e.g. EMU). For this type of MMU, leave +# 'environment_sensor' and 'filament_heater' empty and define a list of heaters and sensors here. Note the length of the list must +# match the number of gates. Leave this settings empty if defining a single sensor/heater. +environment_sensors: # Comma separated list of full environment sensor object names +filament_heaters: # Comma separated list of full heater object names +max_concurrent_heaters: 1 # Limit the number of simultaneously active heaters (power/PSU protection) + + +# FILAMENT DRIVE GEAR STEPPER(S) -------------------------------------------------------------------------------------- +# ██████╗ ███████╗ █████╗ ██████╗ +# ██╔════╝ ██╔════╝██╔══██╗██╔══██╗ +# ██║ ███╗█████╗ ███████║██████╔╝ +# ██║ ██║██╔══╝ ██╔══██║██╔══██╗ +# ╚██████╔╝███████╗██║ ██║██║ ██║ +# ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ +# Note that 'toolhead' & 'mmu_gear' endstops will automatically be added if a toolhead sensor or gate sensor is defined +# +# The default values are tested with the ERCF BOM NEMA14 motor. Please adapt these values to the motor you are using +# Example : for NEMA17 motors, you'll usually use higher current +# +[tmc2209 stepper_mmu_gear] +uart_pin: mmu:PB4 +run_current: 0.7 +hold_current: 0.1 # Recommend to be small if not using "touch" or move (TMC stallguard) +interpolate: True +sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 +stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed +# +# Uncomment two lines below if you have TMC and want the ability to use filament "touch" homing with gear stepper +#diag_pin: mmu:PA3 # Set to MCU pin connected to TMC DIAG pin for gear stepper +#driver_SGTHRS: 60 # 255 is most sensitive value, 0 is least sensitive + +[stepper_mmu_gear] +step_pin: mmu:PB5 +dir_pin: mmu:PB6 +enable_pin: !mmu:PB3 +rotation_distance: 22.96 # Bondtech 5mm Drive Gears. Overridden by 'mmu_gear_rotation_distance' in mmu_vars.cfg +gear_ratio: 50:17 +microsteps: 16 # Recommend 16. Increase only if you "step compress" issues when syncing +full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree +# +# Uncomment the two lines below to enable filament "touch" homing option with gear motor +#extra_endstop_pins: tmc2209_stepper_mmu_gear:virtual_endstop +#extra_endstop_names: mmu_gear_touch + +# ADDITIONAL FILAMENT DRIVE GEAR STEPPERS FOR TYPE-B MMU's ------------------------------------------------------------- +# Note that common parameters are inherited from base stepper_mmu_gear, but can be uniquely specified here too +# +# Filament Drive Gear_1 -------------------------- +[tmc2209 stepper_mmu_gear_1] +uart_pin: mmu:PB8 + +[stepper_mmu_gear_1] +step_pin: mmu:PB9 +dir_pin: !mmu:PC10 +enable_pin: !mmu:PB7 +#diag_pin: mmu:PA4 + +# Filament Drive Gear_2 -------------------------- +[tmc2209 stepper_mmu_gear_2] +uart_pin: mmu:PD3 + +[stepper_mmu_gear_2] +step_pin: mmu:PD4 +dir_pin: mmu:PD5 +enable_pin: !mmu:PD6 +#diag_pin: mmu:PB9 + +# Filament Drive Gear_3 -------------------------- +[tmc2209 stepper_mmu_gear_3] +uart_pin: mmu:PC9 + +[stepper_mmu_gear_3] +step_pin: mmu:PD0 +dir_pin: !mmu:PD1 +enable_pin: !mmu:PD2 +#diag_pin: mmu:PB8 + + +# SERVOS --------------------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ ███████╗ +# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗██╔════╝ +# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║███████╗ +# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║╚════██║ +# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝███████║ +# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ +# Basic servo PWM setup. If these values are changed then the angles defined for different positions will also change +# +# OPTIONAL GANTRY SERVO FOR TOOLHEAD FILAMENT CUTTER ------------------------------------------------------------------ +# +# (uncomment this section if you have a gantry servo for toolhead cutter pin) +#[mmu_servo mmu_gantry_servo] +#pin: +#maximum_servo_angle:180 +#minimum_pulse_width: 0.00075 +#maximum_pulse_width: 0.00225 +#initial_angle: 180 + + +# FILAMENT SENSORS ----------------------------------------------------------------------------------------------------- +# ███████╗███████╗███╗ ██╗███████╗ ██████╗ ██████╗ ███████╗ +# ██╔════╝██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ +# ███████╗█████╗ ██╔██╗ ██║███████╗██║ ██║██████╔╝███████╗ +# ╚════██║██╔══╝ ██║╚██╗██║╚════██║██║ ██║██╔══██╗╚════██║ +# ███████║███████╗██║ ╚████║███████║╚██████╔╝██║ ██║███████║ +# ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ +# Define the pins for optional sensors in the filament path. All but the pre-gate sensors will be automatically setup as +# both endstops (for homing) and sensors for visibility purposes. +# +# 'pre_gate_switch_pin_X' .. 'mmu_pre_gate_X' sensor detects filament at entry to MMU. X=gate number (0..N) +# 'gate_switch_pin' .. 'mmu_gate' shared sensor detects filament past the gate of the MMU +# or +# 'post_gear_switch_pin_X' .. 'mmu_gear_X' post gear sensor for each filament +# 'extruder_switch_pin' .. 'extruder' sensor detects filament just before the extruder entry +# 'toolhead_switch_pin' .. 'toolhead' sensor detects filament after extruder entry +# +# Sync motor feedback will typically have a tension switch (most important for syncing) or both tension and compression. +# Note that compression switch is useful for use as a endstop to detect hitting the extruder entrance +# 'sync_feedback_tension_pin' .. pin for switch activated when filament is under tension +# 'sync_feedback_compression_pin' .. pin for switch activated when filament is under compression +# +# Configuration is flexible: Simply define pins for any sensor you want to enable, if pin is not set (or the alias is empty) +# it will be ignored. You can also just comment out what you are not using. +# +[mmu_sensors] +pre_gate_switch_pin_0: mmu:PA0 +pre_gate_switch_pin_1: mmu:PA1 +pre_gate_switch_pin_2: mmu:PA2 +pre_gate_switch_pin_3: mmu:PA3 + +post_gear_switch_pin_0: mmu:PA5 +post_gear_switch_pin_1: mmu:PA6 +post_gear_switch_pin_2: mmu:PA7 +post_gear_switch_pin_3: mmu:PC4 + +# These sensors can be replicated in a multi-mmu, type-B setup (see num_gates comment). +# If so, then use a comma separated list of per-unit pins instead of single pin +gate_switch_pin: mmu:PC0 + +sync_feedback_tension_pin: !buffer:PA1 +sync_feedback_compression_pin: !buffer:PA0 + +# These sensors are on the toolhead and often controlled by the main printer mcu +#extruder_switch_pin: PG11 +#toolhead_switch_pin: PG13 + + +# ENCODER ------------------------------------------------------------------------------------------------------------- +# ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ +# ██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗ +# █████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗ ██████╔╝ +# ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗ +# ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗██║ ██║ +# ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ +# Encoder measures distance, monitors for runout and clogging and constantly calculates % flow rate +# Note that the encoder_resolution set here is purely a default to get started. It will be correcly set after calibration +# with the value stored in mmu_vars.cfg +# +# The encoder resolution will be calibrated but it needs a default approximation +# If BMG gear based: +# resolution = bmg_circumfrance / (2 * teeth) +# 24 / (2 * 17) = 0.7059 for TRCT5000 based sensor +# 24 / (2 * 12) = 1.0 for Binky with 12 tooth disc +# +[mmu_encoder mmu_encoder] +encoder_pin: mmu:PC5 +encoder_resolution: 0.967 # This is just a starter value. Overriden by calibrated 'mmu_encoder_resolution' in mmm_vars.cfg +desired_headroom: 5.0 # The clog/runout headroom that MMU attempts to maintain (closest point to triggering runout) +average_samples: 4 # The "damping" effect of last measurement (higher value means slower automatic clog_length reduction) +flowrate_samples: 20 # How many "movements" of the extruder to measure average flowrate over + + +# ESPOOLER (OPTIONAL) ------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ +# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ +# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ +# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ +# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ +# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ +# +# An espooler controls DC motors (typically N20) that are able to rewind a filament spool and optionally provide +# forward assist to overcome spooler rotation friction. This should define pins for each of the gates on your mmu +# starting with '_0'. +# An empty pin can be deleted, commented or simply left blank. If you mcu has a separate "enable" pin +# +[mmu_espooler mmu_espooler] +#pwm: 1 # 1=PWM control (typical), 0=digital on/off control +#hardware_pwm: 0 # See klipper doc +#cycle_time: 0.100 # See klipper doc +#scale: 1 # Scales the PWM output range +#value: 0 # See klipper doc +#shutdown_value: 0 # See klipper doc + +assist_motor_pin_0: mmu:PA8 # PWM (or digital) pin for forward motor movement +respool_motor_pin_0: mmu:PB15 # PWM (or digital) pin for rewind/respool movement +#enable_motor_pin_0: # Digital output to enable motors +#assist_trigger_pin_0: # Trigger pin for sensing need to assist during print + +assist_motor_pin_1: mmu:PB13 +respool_motor_pin_1: mmu:PB14 +#enable_motor_pin_1: +#assist_trigger_pin_1: + +assist_motor_pin_2: mmu:PA9 +respool_motor_pin_2: mmu:PC6 +#enable_motor_pin_2: +#assist_trigger_pin_2: + +assist_motor_pin_3: mmu:PD8 +respool_motor_pin_3: mmu:PC7 +#enable_motor_pin_3: +#assist_trigger_pin_3: + + +# LED SUPPORT (OPTIONAL) ----------------------------------------------------------------------------------------------- +# ██╗ ███████╗██████╗ ███████╗ +# ██║ ██╔════╝██╔══██╗██╔════╝ +# ██║ █████╗ ██║ ██║███████╗ +# ██║ ██╔══╝ ██║ ██║╚════██║ +# ███████╗███████╗██████╔╝███████║ +# ╚══════╝╚══════╝╚═════╝ ╚══════╝ +# +# Define mmu leds, both the "neopixel" config and [mmu_led] to define their purpose +# +[neopixel mmu_leds] +pin: mmu:PC13 +chain_count: 4 +color_order: GRBW + +# MMU LED EFFECT SEGMENTS ---------------------------------------------------------------------------------------------- +# Define neopixel LEDs for your MMU. The chain_count must be large enough for your desired ranges: +# exit .. this set of LEDs, one for every gate, usually would be mounted at the exit point of the gate +# entry .. this set of LEDs, one for every gate, could be mounted at the entry point of filament into this MMU/buffer +# status .. these LED. represents the status of the MMU (and selected filament). More than one status LED is possible +# logo .. these LEDs don't change during operation and are designed lighting a logo. Multiple logo LEDs are possible +# +# Note that all sets are optional. You can opt to just have the 'exit' set for example. The advantage to having +# both entry and exit LEDs is, for example, so that 'entry' can display gate status while 'exit' displays the color +# +# The animation effects requires the installation of Julian Schill's awesome LED effect module otherwise the LEDs +# will be static: +# https://github.com/julianschill/klipper-led_effect +# +# LED's are indexed in the chain from 1..N. Thus to set up LED's on 'exit' and a single 'status' LED on a 4 gate MMU: +# +# exit_leds: neopixel:mmu_leds (1,2,3,4) +# status_leds: neopixel:mmu_leds (5) +# +# In this example no 'entry' set is configured. Note that constructs like "mmu_leds (1-3,4)" are also valid +# +# The range is completely flexible and can be comprised of different led strips, individual LEDs, or combinations of +# both on different pins. In addition, the ordering is flexible based on your wiring, thus (1-4) and (4-1) both +# represent the same LED range but mapped to increasing or decreasing gates respectively. E.g if you have two Box +# Turtle MMUs, one with a chain of LEDs wired in reverse order and another with individual LEDs, to define 8 exit LEDs: +# +# exit_leds: neopixel:bt_1 (4-1) +# neopixel:bt_2a +# neopixel:bt_2b +# neopixel:bt_2c +# neopixel:bt_2d +# +# Note the use of separate lines for each part of the definition, +# +# ADVANCED: Happy Hare provides a convenience wrapper [mmu_led_effect] that not only creates an effect on each of the +# [mmu_leds] specified segments as a whole but also each individual LED for atomic control. See mmu_leds.cfg for examples +# +# (comment out this whole section if you don't have/want leds; uncomment/edit LEDs fitted on your MMU) +# +[mmu_leds unit0] +exit_leds: neopixel:mmu_leds (1-4) +frame_rate: 24 + +# Default effects for LED segments when not providing action status +# off - LED's off +# on - LED's white +# gate_status - indicate gate availability / status (printer.mmu.gate_status) +# filament_color - display filament color defined in gate map (printer.mmu.gate_color_rgb) +# slicer_color - display slicer defined set color for each gate (printer.mmu.slicer_color_rgb) +# (r,g,b) - display static r,g,b color e.g. "0,0,0.3" for dim blue +# _effect_ - display the named led effect +# +enabled: True # True = LEDs are enabled at startup (MMU_LED can control), False = Disabled +animation: True # True = Use led-animation-effects, False = Static LEDs +exit_effect: gate_status # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +entry_effect: filament_color # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +status_effect: filament_color # on|off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +logo_effect: (0, 0, 0.3) # off |r,g,b|_effect_ +white_light: (1, 1, 1) # RGB color for static white light +black_light: (.01, 0, .02) # RGB color used to represent "black" (filament) +empty_light: (0, 0, 0) # RGB color used to represent empty gate + +# Default effects (animation: True) / static rbg (animation False) to apply to actions +# effect_name, (r,b,g) +# +# IMPORTANT: Effects must be from [mmu_led_effects] set defined in mmu_hardware.cfg +# +effect_loading: mmu_blue_clockwise_slow, (0, 0, 0.4) +effect_loading_extruder: mmu_blue_clockwise_fast, (0, 0, 1) +effect_unloading: mmu_blue_anticlock_slow, (0, 0, 0.4) +effect_unloading_extruder: mmu_blue_anticlock_fast, (0, 0, 1) +effect_heating: mmu_breathing_red, (0.3, 0, 0) +effect_selecting: mmu_white_fast, (0.2, 0.2, 0.2) +effect_checking: mmu_white_fast, (0.8, 0.8, 0.8) +effect_initialized: mmu_rainbow, (0.5, 0.2, 0) +effect_error: mmu_strobe, (1, 0, 0) +effect_complete: mmu_sparkle, (0.3, 0.3, 0.3) +effect_gate_selected: mmu_static_blue, (0, 0, 1) +effect_gate_available: mmu_static_green, (0, 0.5, 0) +effect_gate_available_sel: mmu_ready_green, (0, 0.75, 0) +effect_gate_unknown: mmu_static_orange, (0.5, 0.2, 0) +effect_gate_unknown_sel: mmu_ready_orange, (0.75, 0.3, 0) +effect_gate_empty: mmu_static_black, (0, 0, 0) +effect_gate_empty_sel: mmu_ready_orange2, (0.1, 0.04, 0) + + +# ADDITIONAL HARDWARE ------------------------------------------------------------------------------------------------- +# ███╗ ███╗██╗███████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ███████╗ +# ████╗ ████║██║██╔════╝██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔════╝ +# ██╔████╔██║██║███████╗██║ ███████║███████║██████╔╝██║ ██║██║ █╗ ██║███████║██████╔╝█████╗ +# ██║╚██╔╝██║██║╚════██║██║ ██╔══██║██╔══██║██╔══██╗██║ ██║██║███╗██║██╔══██║██╔══██╗██╔══╝ +# ██║ ╚═╝ ██║██║███████║╚██████╗ ██║ ██║██║ ██║██║ ██║██████╔╝╚███╔███╔╝██║ ██║██║ ██║███████╗ +# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ +# +# Define any additional hardware for this MMU unit, e.g. heaters, chamber temperature probes, etc +# +[temperature_sensor MMU_enviroment_kms] +sensor_type: HTU21D +i2c_mcu: mmu +htu21d_report_time: 10 +i2c_software_scl_pin: mmu:PB10 +i2c_software_sda_pin: mmu:PB11 + +[heater_fan MMU_kms_box_left_fan] +pin: mmu:PC3 +shutdown_speed: 0 +heater: MMU_heater_kms + +[heater_fan MMU_kms_box_right_fan] +pin: mmu:PC1 +shutdown_speed: 0 +heater: MMU_heater_kms + +[heater_generic MMU_heater_kms] +gcode_id: mmu_heater_kms +heater_pin: mmu:PB1 +max_power:1.0 +sensor_type: EPCOS 100K B57560G104F +pullup_resistor: 2200 +sensor_pin: mmu:PA4 +control:watermark +#control: pid +#pid_Kp= +#pid_Ki= +#pid_Kd= +min_temp:0 +max_temp:70 + +[verify_heater MMU_heater_kms] +max_error: 300 +check_gain_time: 600 +hysteresis: 10 +heating_gain: 1 diff --git a/config/base/mmu_hardware.cfg.vvd b/config/base/mmu_hardware.cfg.vvd new file mode 100644 index 000000000000..dfda737aa42b --- /dev/null +++ b/config/base/mmu_hardware.cfg.vvd @@ -0,0 +1,401 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Happy Hare MMU hardware config file with config for BTT ViViD MCU board +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# Notes about setup of common external MCUs can be found here: +# https://github.com/moggieuk/Happy-Hare/blob/main/doc/mcu_notes.md +# +# Note about "touch" endstops: Happy Hare provides extremely flexible homing options using both single steppers or +# synced steppers. The "touch" option leverages stallguard and thus requires the appropriate 'diag_pin' and stallguard +# parameters set on the TMC driver section. If you have the diag_pin exposed, it is harmless to define this because +# they will only be used when explicitly needed and configured. +# +# Touch option for each stepper provides these benefits / possibilities (experimental): +# - on extruder stepper allows for the automatic detection of the nozzle! +# - on selector stepper allows for the automatic detection of filament stuck in the gate and subsequent recovery +# - on gear stepper allows for the automatic detection of the extruder entrance +# +# In summary, "touch" homing with your MMU is an advanced option that requires patience and careful tuning. Everything +# works with regular endstops and there are workaround options for certain homing points (like extruder entry) in +# the absence of any endstop. I'm really interested in creative setups. Ping me on Discord (moggieuk#6538) +# +# See 'mmu.cfg' for serial definition and pins aliases +# +# HOMING CAPABLE EXTRUDER (VERY ADVANCED) ----------------------------------------------------------------------------- +# With Happy Hare installed even the extruder can be homed. You will find the usual 'endstop' parameters can be added +# to your '[extruder]' section. Useless you have some clever load cell attached to your nozzle it only really makes +# sense to configure stallguard style "touch" homing. To do this add lines similar to this to your existing +# '[extruder]' definition in printer.cfg. +# +# [extruder] +# endstop_pin: tmc2209_extruder:virtual_endstop +# +# Also be sure to add the appropriate stallguard config to the TMC section, e.g. +# +# [tmc2209 extruder] +# diag_pin: E_DIAG # Set to MCU pin connected to TMC DIAG pin for extruder +# driver_SGTHRS: 100 # 255 is most sensitive value, 0 is least sensitive +# +# Happy Hare will take care of the rest and add a 'mmu_ext_touch' endstop automatically +# + + +# MMU MACHINE / TYPE --------------------------------------------------------------------------------------------------- +# ███╗ ███╗███╗ ███╗██╗ ██╗ ███╗ ███╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗███████╗ +# ████╗ ████║████╗ ████║██║ ██║ ████╗ ████║██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝ +# ██╔████╔██║██╔████╔██║██║ ██║ ██╔████╔██║███████║██║ ███████║██║██╔██╗ ██║█████╗ +# ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ ██║╚██╔╝██║██╔══██║██║ ██╔══██║██║██║╚██╗██║██╔══╝ +# ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║██║██║ ╚████║███████╗ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ +[mmu_machine] + +# Number of selectable gate on (each) MMU. Generally this is a single number, but with multi-mmu (type-B) setups +# it can be a comma separated list of the number of gates per unit. +# E.g. 'num_gates: 4,4,2' for a 2xBox Turtle and 1xNight Owl multiplexed setup +# +num_gates: 4 + +# MMU Vendor & Version is used to automatically configure some parameters and validate configuration +# If custom set to "Other" and uncomment the additional parameters below +# +mmu_vendor: VVD # MMU family +mmu_version: 1.0 # MMU hardware version number (add mod suffix documented above) + +# The following attributes are set internally from vendor/version above. Only uncomment to customize the vendor +# default or for custom ("Other") designs +# +#selector_type: IndexedSelector # E.g. LinearSelector (type-A), VirtualSelector (type-B), MacroSelector, RotarySelector, ... +#variable_bowden_lengths: 0 # 1 = If MMU design has different bowden lengths per gate, 0 = bowden length is the same +#variable_rotation_distances: 1 # 1 = If MMU design has dissimilar drive/BMG gears, thus rotation distance, 0 = One drive gear (e.g. Tradrack) +#require_bowden_move: 1 # 1 = If MMU design has bowden move that is included in load/unload, 0 = zero length bowden (skip bowden move) +#filament_always_gripped: 1 # 1 = Filament is always trapped by MMU (most type-B designs), 0 = MMU can release filament +#has_bypass: {has_bypass} # 1 = Integrated bypass gate available, 0 = No integrated bypass; Bypassing only possible via PTFE + +homing_extruder: 1 # CAUTION: Normally this should be 1. 0 will disable the homing extruder capability + +# Uncomment to change the display name in UI's. Defaults to the vendor name +#display_name: My Precious + +# Fullname of environment sensor for MMU filament storage (displays temp and humidity in UI). Leave empty if sensor is not fitted +# Polls for "temperature" and "humidity" +# E.g. If you have a section like this: [temperature_sensor MMU_enclosure] +# Set: environment_sensor: temperature_sensor MMU_enclosure +environment_sensor:temperature_sensor ptc_ntc100k + +# Full name of filament heater object. Leave empty if heater is not fitted +# E.g. If you have a section like this: [heater_generic MMU_heater] +# Set: filament_heater: heater_generic MMU_heater +filament_heater: heater_generic MMU_heater_vivid + +# PER-GATE heaters and environment sensors. Some modular MMU designs have per-gate control (e.g. EMU). For this type of MMU, leave +# 'environment_sensor' and 'filament_heater' empty and define a list of heaters and sensors here. Note the length of the list must +# match the number of gates. Leave this settings empty if defining a single sensor/heater. +environment_sensors: # Comma separated list of full environment sensor object names +filament_heaters: # Comma separated list of full heater object names +max_concurrent_heaters: 1 # Limit the number of simultaneously active heaters (power/PSU protection) + + +# FILAMENT DRIVE GEAR STEPPER(S) -------------------------------------------------------------------------------------- +# ██████╗ ███████╗ █████╗ ██████╗ +# ██╔════╝ ██╔════╝██╔══██╗██╔══██╗ +# ██║ ███╗█████╗ ███████║██████╔╝ +# ██║ ██║██╔══╝ ██╔══██║██╔══██╗ +# ╚██████╔╝███████╗██║ ██║██║ ██║ +# ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ +# +# Note that 'toolhead' & 'mmu_gear' endstops will automatically be added if a toolhead sensor or gate sensor is defined +# + +[tmc2209 stepper_mmu_gear] +uart_pin: mmu:PB7 +run_current: 0.7 +hold_current: 0.1 # Recommend to be small if not using "touch" or move (TMC stallguard) +interpolate: True +sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 +stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed + +[stepper_mmu_gear] +step_pin: mmu:PB6 +dir_pin: mmu:PB5 +enable_pin: !mmu:PB8 +rotation_distance: 8.0 # This is highly variable -- tune each gate with MMU_CALIBRATE_GEAR +gear_ratio: 1:1 +microsteps: 16 +full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree + + +# SELECTOR STEPPER ---------------------------------------------------------------------------------------------------- +# ███████╗███████╗██╗ ███████╗ ██████╗████████╗ ██████╗ ██████╗ +# ██╔════╝██╔════╝██║ ██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ +# ███████╗█████╗ ██║ █████╗ ██║ ██║ ██║ ██║██████╔╝ +# ╚════██║██╔══╝ ██║ ██╔══╝ ██║ ██║ ██║ ██║██╔══██╗ +# ███████║███████╗███████╗███████╗╚██████╗ ██║ ╚██████╔╝██║ ██║ +# ╚══════╝╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ +# +# Consult doc if you want to setup selector for "touch" homing instead or physical endstop +# +[tmc2209 stepper_mmu_selector] +uart_pin: mmu:PB3 +run_current: 0.8 +hold_current: 0.4 # Recommend to be small if not using "touch" or move (TMC stallguard) +interpolate: True +sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 +stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed + +[stepper_mmu_selector] +step_pin: mmu:PD3 +dir_pin: mmu:PD2 +enable_pin: !mmu:PB4 +rotation_distance: 360 +gear_ratio: 25:10 +microsteps: 16 # Don't need high fidelity +full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree +extra_endstop_pins: !mmu:PD0, !mmu:PA15, !mmu:PD1, !mmu:PC13 +extra_endstop_names: unit0_gate0, unit0_gate1, unit0_gate2, unit0_gate3 + + +# SERVOS --------------------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ ███████╗ +# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗██╔════╝ +# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║███████╗ +# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║╚════██║ +# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝███████║ +# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ +# Basic servo PWM setup. If these values are changed then the angles defined for different positions will also change +# +# OPTIONAL GANTRY SERVO FOR TOOLHEAD FILAMENT CUTTER ------------------------------------------------------------------ +# +# (uncomment this section if you have a gantry servo for toolhead cutter pin) +#[mmu_servo mmu_gantry_servo] +#pin: +#maximum_servo_angle:180 +#minimum_pulse_width: 0.00075 +#maximum_pulse_width: 0.00225 +#initial_angle: 180 + + +# FILAMENT SENSORS ----------------------------------------------------------------------------------------------------- +# ███████╗███████╗███╗ ██╗███████╗ ██████╗ ██████╗ ███████╗ +# ██╔════╝██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ +# ███████╗█████╗ ██╔██╗ ██║███████╗██║ ██║██████╔╝███████╗ +# ╚════██║██╔══╝ ██║╚██╗██║╚════██║██║ ██║██╔══██╗╚════██║ +# ███████║███████╗██║ ╚████║███████║╚██████╔╝██║ ██║███████║ +# ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ +# Define the pins for optional sensors in the filament path. All but the pre-gate sensors will be automatically setup as +# both endstops (for homing) and sensors for visibility purposes. +# +# 'pre_gate_switch_pin_X' .. 'mmu_pre_gate_X' sensor detects filament at entry to MMU. X=gate number (0..N) +# 'gate_switch_pin' .. 'mmu_gate' shared sensor detects filament past the gate of the MMU +# or +# 'post_gear_switch_pin_X' .. 'mmu_gear_X' post gear sensor for each filament +# 'extruder_switch_pin' .. 'extruder' sensor detects filament just before the extruder entry +# 'toolhead_switch_pin' .. 'toolhead' sensor detects filament after extruder entry +# +# Sync motor feedback will typically have a tension switch (most important for syncing) or both tension and compression. +# Note that compression switch is useful for use as a endstop to detect hitting the extruder entrance +# 'sync_feedback_tension_pin' .. pin for switch activated when filament is under tension +# 'sync_feedback_compression_pin' .. pin for switch activated when filament is under compression +# +# Configuration is flexible: Simply define pins for any sensor you want to enable, if pin is not set (or the alias is empty) +# it will be ignored. You can also just comment out what you are not using. +# +[mmu_sensors] +pre_gate_switch_pin_0: mmu:PA0 +pre_gate_switch_pin_1: mmu:PA1 +pre_gate_switch_pin_2: mmu:PA2 +pre_gate_switch_pin_3: mmu:PA3 + +post_gear_switch_pin_0: buffer:PA0 +#post_gear_analog_range_0: 0, 500 # Delete if using updated digital sensor +post_gear_switch_pin_1: buffer:PA1 +#post_gear_analog_range_1: 0, 500 # Delete if using updated digital sensor +post_gear_switch_pin_2: buffer:PA2 +#post_gear_analog_range_2: 0, 500 # Delete if using updated digital sensor +post_gear_switch_pin_3: buffer:PA3 +#post_gear_analog_range_3: 0, 500 # Delete if using updated digital sensor + +sync_feedback_tension_pin: buffer:PA4 +sync_feedback_compression_pin: buffer:PA5 + +# These sensors are on the toolhead and often controlled by the main printer mcu +#extruder_switch_pin: PG11 +#toolhead_switch_pin: PG13 + + +# LED SUPPORT (OPTIONAL) ----------------------------------------------------------------------------------------------- +# ██╗ ███████╗██████╗ ███████╗ +# ██║ ██╔════╝██╔══██╗██╔════╝ +# ██║ █████╗ ██║ ██║███████╗ +# ██║ ██╔══╝ ██║ ██║╚════██║ +# ███████╗███████╗██████╔╝███████║ +# ╚══════╝╚══════╝╚═════╝ ╚══════╝ +# +# Define mmu leds, both the "neopixel" config and [mmu_led] to define their purpose +# +[neopixel mmu_leds_1] +pin: mmu:PC6 +chain_count: 14 +color_order: GRB + +[neopixel mmu_leds_2] +pin: mmu:PC7 +chain_count: 14 +color_order: GRB + +# MMU LED EFFECT SEGMENTS ---------------------------------------------------------------------------------------------- +# Define neopixel LEDs for your MMU. The chain_count must be large enough for your desired ranges: +# exit .. this set of LEDs, one for every gate, usually would be mounted at the exit point of the gate +# entry .. this set of LEDs, one for every gate, could be mounted at the entry point of filament into this MMU/buffer +# status .. these LED. represents the status of the MMU (and selected filament). More than one status LED is possible +# logo .. these LEDs don't change during operation and are designed lighting a logo. Multiple logo LEDs are possible +# +# Note that all sets are optional. You can opt to just have the 'exit' set for example. The advantage to having +# both entry and exit LEDs is, for example, so that 'entry' can display gate status while 'exit' displays the color +# +# The animation effects requires the installation of Julian Schill's awesome LED effect module otherwise the LEDs +# will be static: +# https://github.com/julianschill/klipper-led_effect +# +# LED's are indexed in the chain from 1..N. Thus to set up LED's on 'exit' and a single 'status' LED on a 4 gate MMU: +# +# exit_leds: neopixel:mmu_leds (1,2,3,4) +# status_leds: neopixel:mmu_leds (5) +# +# In this example no 'entry' set is configured. Note that constructs like "mmu_leds (1-3,4)" are also valid +# +# The range is completely flexible and can be comprised of different led strips, individual LEDs, or combinations of +# both on different pins. In addition, the ordering is flexible based on your wiring, thus (1-4) and (4-1) both +# represent the same LED range but mapped to increasing or decreasing gates respectively. E.g if you have two Box +# Turtle MMUs, one with a chain of LEDs wired in reverse order and another with individual LEDs, to define 8 exit LEDs: +# +# exit_leds: neopixel:bt_1 (4-1) +# neopixel:bt_2a +# neopixel:bt_2b +# neopixel:bt_2c +# neopixel:bt_2d +# +# Note the use of separate lines for each part of the definition, +# +# ADVANCED: Happy Hare provides a convenience wrapper [mmu_led_effect] that not only creates an effect on each of the +# [mmu_leds] specified segments as a whole but also each individual LED for atomic control. See mmu_leds.cfg for examples +# +# (comment out this whole section if you don't have/want leds; uncomment/edit LEDs fitted on your MMU) +# +[mmu_leds unit0] +exit_leds: neopixel:mmu_leds_1 (1-14) + neopixel:mmu_leds_2 (1-14) +frame_rate: 24 + +# Default effects for LED segments when not providing action status +# off - LED's off +# on - LED's white +# gate_status - indicate gate availability / status (printer.mmu.gate_status) +# filament_color - display filament color defined in gate map (printer.mmu.gate_color_rgb) +# slicer_color - display slicer defined set color for each gate (printer.mmu.slicer_color_rgb) +# (r,g,b) - display static r,g,b color e.g. "0,0,0.3" for dim blue +# _effect_ - display the named led effect +# +enabled: True # True = LEDs are enabled at startup (MMU_LED can control), False = Disabled +animation: True # True = Use led-animation-effects, False = Static LEDs +exit_effect: gate_status # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +entry_effect: filament_color # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +status_effect: filament_color # on|off|gate_status|filament_color|slicer_color|r,g,b|_effect_ +logo_effect: (0, 0, 0.3) # off |r,g,b|_effect_ +white_light: (1, 1, 1) # RGB color for static white light +black_light: (.01, 0, .02) # RGB color used to represent "black" (filament) +empty_light: (0, 0, 0) # RGB color used to represent empty gate + +# Default effects (animation: True) / static rbg (animation False) to apply to actions +# effect_name, (r,b,g) +# +# IMPORTANT: Effects must be from [mmu_led_effects] set defined in mmu_hardware.cfg +# +effect_loading: mmu_blue_clockwise_slow, (0, 0, 0.4) +effect_loading_extruder: mmu_blue_clockwise_fast, (0, 0, 1) +effect_unloading: mmu_blue_anticlock_slow, (0, 0, 0.4) +effect_unloading_extruder: mmu_blue_anticlock_fast, (0, 0, 1) +effect_heating: mmu_breathing_red, (0.3, 0, 0) +effect_selecting: mmu_white_fast, (0.2, 0.2, 0.2) +effect_checking: mmu_white_fast, (0.8, 0.8, 0.8) +effect_initialized: mmu_rainbow, (0.5, 0.2, 0) +effect_error: mmu_strobe, (1, 0, 0) +effect_complete: mmu_sparkle, (0.3, 0.3, 0.3) +effect_gate_selected: mmu_static_blue, (0, 0, 1) +effect_gate_available: mmu_static_green, (0, 0.5, 0) +effect_gate_available_sel: mmu_ready_green, (0, 0.75, 0) +effect_gate_unknown: mmu_static_orange, (0.5, 0.2, 0) +effect_gate_unknown_sel: mmu_ready_orange, (0.75, 0.3, 0) +effect_gate_empty: mmu_static_black, (0, 0, 0) +effect_gate_empty_sel: mmu_ready_orange2, (0.1, 0.04, 0) + + +# ADDITIONAL HARDWARE ------------------------------------------------------------------------------------------------- +# ███╗ ███╗██╗███████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ███████╗ +# ████╗ ████║██║██╔════╝██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔════╝ +# ██╔████╔██║██║███████╗██║ ███████║███████║██████╔╝██║ ██║██║ █╗ ██║███████║██████╔╝█████╗ +# ██║╚██╔╝██║██║╚════██║██║ ██╔══██║██╔══██║██╔══██╗██║ ██║██║███╗██║██╔══██║██╔══██╗██╔══╝ +# ██║ ╚═╝ ██║██║███████║╚██████╗ ██║ ██║██║ ██║██║ ██║██████╔╝╚███╔███╔╝██║ ██║██║ ██║███████╗ +# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ +# +# Define any additional hardware for this MMU unit, e.g. heaters, chamber temperature probes, etc +# +[temperature_sensor MMU_vivid_left] +sensor_type: AHT3X # Use an AHT10 and set 'update_aht10_commands: 1' in mmu_parameters.cfg if the AHT3X sensor_type isn't supported (older Klipper versions) +i2c_mcu: mmu +i2c_software_scl_pin: mmu:PA9 +i2c_software_sda_pin: mmu:PA10 +aht10_report_time: 10 + +[temperature_sensor MMU_vivid_right] +sensor_type: AHT3X # Use an AHT10 and set 'update_aht10_commands: 1' in mmu_parameters.cfg if the AHT3X sensor_type isn't supported (older Klipper versions) +i2c_mcu: mmu +i2c_software_scl_pin: mmu:PA7 +i2c_software_sda_pin: mmu:PA6 +aht10_report_time: 10 + +[heater_generic MMU_heater_vivid] +gcode_id: mmu_heater_vivid +heater_pin: mmu:PC14 +sensor_type: temperature_combined +sensor_list: temperature_sensor MMU_vivid_left, temperature_sensor MMU_vivid_right +combination_method: mean +maximum_deviation: 100 +control: pid +pid_Kp=51.604 +pid_Ki=1.121 +pid_Kd=593.934 +min_temp: -100 +max_temp: 70 + +[verify_heater MMU_heater_vivid] +max_error: 300 +check_gain_time: 600 +hysteresis: 10 +heating_gain: 1 + +[heater_fan MMU_vivid_box_fan] +pin: mmu:PC15 +shutdown_speed: 0 +heater: MMU_heater_vivid + +# Electronics cooling fan +[controller_fan my_controller_fan] +pin: mmu:PA14 +stepper: stepper_mmu_selector, stepper_mmu_gear + +[temperature_sensor ptc_ntc100k] +sensor_pin: mmu:PA4 +sensor_type: Generic 3950 +pullup_resistor: 2200 diff --git a/config/base/mmu_heater_vent.cfg b/config/base/mmu_heater_vent.cfg new file mode 100644 index 000000000000..2de624b1920e --- /dev/null +++ b/config/base/mmu_heater_vent.cfg @@ -0,0 +1,56 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Outline (very simplistic reference) macro that can could be called from 'heater_vent_macro` +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# Copy this macro to another file of your own, rename the macro names and set 'heater_vent_macro' +# and 'heater_vent_interval' accordingly. Then complete the logic for your MMU to control venting. +# As it stands it will simple log the intent to the console and mmu.log +# NOTE: this can be called during a print so don't add delays or M400 constructs +# (read the wiki: Heater & Environment Control pages) +# +# heater_vent_macro: _MMU_VENT +# heater_vent_interval: +# +[gcode_macro _MMU_VENT] +description: Simple reference MMU enclosure venting control + +gcode: + # GATES param can generally be ignored unless MMU design has individual spool enclosures that can operate independently + {% set gates = (params.GATES | default('')).split(',') | map('trim') | list %} + + {% if gates == [''] %} + + MMU_LOG MSG="Opening MMU vent..." + # Add logic to operate servo to open vent here, perhaps also turn on/up extraction fan + + {% else %} + + MMU_LOG MSG="Opening MMU vent to dry filaments in gates: {", ".join(gates)}..." + {% for gate in range(gate) %} + # Open vent servo_{gate} + {% endfor %} + + {% endif %} + + + # Close the vent after 10 seconds + UPDATE_DELAYED_GCODE ID=_MMU_VENT_CLOSE DURATION=10 + + +[delayed_gcode _MMU_VENT_CLOSE] +gcode: + MMU_LOG MSG="Closing MMU vent..." + # Add logic to operate servo to close vent here, perhaps also turn off/down extraction fan diff --git a/config/base/mmu_leds.cfg b/config/base/mmu_leds.cfg new file mode 100644 index 000000000000..d85b9b90ee4e --- /dev/null +++ b/config/base/mmu_leds.cfg @@ -0,0 +1,89 @@ +# +# NOTE: This is a temporary pre-inclusion of Happy Hare v4 functionality for the v3.4.0 release +# + +# LED EFFECTS --------------------------------------------------------------------------------------------------------- +# ██╗ ███████╗██████╗ ███████╗███████╗███████╗███████╗ ██████╗████████╗███████╗ +# ██║ ██╔════╝██╔══██╗ ██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ +# ██║ █████╗ ██║ ██║ █████╗ █████╗ █████╗ █████╗ ██║ ██║ ███████╗ +# ██║ ██╔══╝ ██║ ██║ ██╔══╝ ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║ ╚════██║ +# ███████╗███████╗██████╔╝ ███████╗██║ ██║ ███████╗╚██████╗ ██║ ███████║ +# ╚══════╝╚══════╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚══════╝ +# +# These are the default effects that may be assigned to actions in the [mmu_leds] section. Note that 'define_on' +# is optional and if sepcified can be used to restrict effect creation to the desired segments +# +[mmu_led_effect mmu_breathing_red] +layers: breathing 4 0 top (1,0,0) + +[mmu_led_effect mmu_white_slow] +define_on: exit, status +layers: breathing 1.0 0 top (0.8,0.8,0.8) + +[mmu_led_effect mmu_white_fast] +define_on: exit, status +layers: breathing 0.6 0 top (0.2,0.2,0.2) + +[mmu_led_effect mmu_blue_clockwise_slow] +define_on: gates, status +layers: chase .5 .5 top (0,0,1), (0,0,.4) + +[mmu_led_effect mmu_blue_clockwise_fast] +define_on: gates, status +layers: chase 1 .5 top (0,0,1), (0,0,.4) + +[mmu_led_effect mmu_blue_anticlock_slow] +define_on: gates, status +layers: chase -.5 .5 top (0,0,1), (0,0,.4) + +[mmu_led_effect mmu_blue_anticlock_fast] +define_on: gates, status +layers: chase -1 .5 top (0,0,1), (0,0,.4) + +[mmu_led_effect mmu_strobe] +layers: strobe 1 1.5 add (1,1,1) + breathing 2 0 difference (0.95,0,0) + static 0 0 top (1,0,0) + +[mmu_led_effect mmu_static_green] +define_on: gates +layers: static 0 0 top (0,0.5,0) + +[mmu_led_effect mmu_ready_green] +define_on: gates +layers: breathing 2 0 subtract (0,0.35,0) + static 0 0 top (0,0.5,0) + +[mmu_led_effect mmu_static_orange] +define_on: gates +layers: static 0 0 top (0.5,0.2,0) + +[mmu_led_effect mmu_ready_orange] +define_on: gates +layers: breathing 2 0 subtract (0.35,0.14,0) + static 0 0 top (0.5,0.2,0) + +[mmu_led_effect mmu_ready_orange2] +define_on: gates +layers: breathing 2 0 top (0.175,0.07,0) + +[mmu_led_effect mmu_static_blue] +define_on: gates +layers: static 0 0 top (0,0,1) + +[mmu_led_effect mmu_ready_blue] +define_on: gates +layers: breathing 2 0 add (0,0,0.5) + static 0 0 top (0,0,0) + +[mmu_led_effect mmu_static_black] +define_on: gates +layers: static 0 0 top (0,0,0) + +[mmu_led_effect mmu_rainbow] +define_on: entry, exit, status +layers: gradient 0.8 0.5 add (0.3, 0.0, 0.0), (0.0, 0.3, 0.0), (0.0, 0.0, 0.3) + +[mmu_led_effect mmu_sparkle] +define_on: exit +layers: twinkle 8 0.15 top (0.3,0.3,0.3), (0.4,0,0.25) diff --git a/config/base/mmu_macro_vars.cfg b/config/base/mmu_macro_vars.cfg new file mode 100644 index 000000000000..c515b7c51db9 --- /dev/null +++ b/config/base/mmu_macro_vars.cfg @@ -0,0 +1,485 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Happy Hare supporting MACRO configuration +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# Supporting set of macros supplied with Happy Hare can be customized by editing the macro "variables" declared here. +# +# This configuration will automatically retained and upgraded between releases (a backup of previous config files will +# always be made for your reference). If you want to customize macros beyond what is possible through these variables +# it is highly recommended you copy the macro to a new name and change the callback macro name in 'mmu_parameters.cfg' +# That way the default macros can still be upgraded but your customization will be left intact +# + + +# PERSISTED STATE --------------------------------------------------------- +# Happy Hare stores configuration and state in the klipper variables file. +# Since klipper can only be a single 'save_variables' file, if you already +# have one you will need to merge the two and point this appropriately. +# +[save_variables] +filename: ~/printer_data/config/mmu/mmu_vars.cfg + + +# NECESSARY KLIPPER OVERRIDES --------------------------------------------- +# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ +# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ +# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ +# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ +# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +# These supplemental settings essentially disable klipper's built in +# extrusion limits and is necessary when using an MMU +[extruder] +max_extrude_only_distance: 200 +max_extrude_cross_section: 50 + +# For dialog prompts and progress in Mainsail. Requires Mainsail version >= v2.9.0 +[respond] + +# Other Happy Hare prerequisites. Harmless if already defined elsewhere in user config +[display_status] +[pause_resume] +[virtual_sdcard] +path: ~/printer_data/gcodes +#on_error_gcode: CANCEL_PRINT + + +# PRINT START/END --------------------------------------------------------- +# ██████╗ ██████╗ ██╗███╗ ██╗████████╗ ███████╗████████╗ █████╗ ██████╗ ████████╗ +# ██╔══██╗██╔══██╗██║████╗ ██║╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝ +# ██████╔╝██████╔╝██║██╔██╗ ██║ ██║ ███████╗ ██║ ███████║██████╔╝ ██║ +# ██╔═══╝ ██╔══██╗██║██║╚██╗██║ ██║ ╚════██║ ██║ ██╔══██║██╔══██╗ ██║ +# ██║ ██║ ██║██║██║ ╚████║ ██║ ███████║ ██║ ██║ ██║██║ ██║ ██║ +# ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ +# (base/mmu_software.cfg) +# +[gcode_macro _MMU_SOFTWARE_VARS] +description: Happy Hare optional configuration for print start/end checks +gcode: # Leave empty + +# These variables control the behavior of the MMU_START_SETUP and MMU_START_LOAD_INITIAL_TOOL macros +variable_user_pre_initialize_extension : '' ; Executed at start of MMU_START_SETUP. Commonly G28 to home +variable_home_mmu : False ; True/False, Whether to home mmu before print starts +variable_check_gates : True ; True/False, Whether to check filament is loaded in all gates used +variable_load_initial_tool : True ; True/False, Whether to automatically load initial tool +# +# Automapping strategy to apply slicer tool map to find matching MMU gate (will adjust tool-to-gate map). Options are: +# 'none' - don't automap (i.e. don't update tool-to-gate map) +# 'filament_name' - exactly match on case insensitive filament name +# 'material' - exactly match on material +# 'color' - exactly match on color (with same material) +# 'closest_color' - match to closest available filament color (with same material) +# 'spool_id' - exactly match on spool_id [FUTURE] +variable_automap_strategy : "none" ; none|filament_name|material|color|closest_color|spool_id + +# These variables control the behavior of the MMU_END macro +variable_user_print_end_extension : '' ; Executed at start of MMU_END. Good place to move off print +variable_unload_tool : True ; True/False, Whether to unload the tool at the end of the print +variable_reset_ttg : False ; True/False, Whether reset TTG map at end of print +variable_dump_stats : True ; True/False, Whether to display print stats at end of print + + +# STATE MACHINE CHANGES --------------------------------------------------- +# ███████╗████████╗ █████╗ ████████╗███████╗ ██████╗██╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ███████╗ +# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ ██╔════╝██║ ██║██╔══██╗████╗ ██║██╔════╝ ██╔════╝ +# ███████╗ ██║ ███████║ ██║ █████╗ ██║ ███████║███████║██╔██╗ ██║██║ ███╗█████╗ +# ╚════██║ ██║ ██╔══██║ ██║ ██╔══╝ ██║ ██╔══██║██╔══██║██║╚██╗██║██║ ██║██╔══╝ +# ███████║ ██║ ██║ ██║ ██║ ███████╗ ╚██████╗██║ ██║██║ ██║██║ ╚████║╚██████╔╝███████╗ +# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ +# (base/mmu_state.cfg) +# +[gcode_macro _MMU_STATE_VARS] +description: Happy Hare configuration for state change hooks +gcode: # Leave empty + +# You can extend functionality to all Happy Hare state change or event +# macros by adding a command (or call to your gcode macro). +# E.g for additional LED logic or consumption counters +variable_user_action_changed_extension : '' ; Executed after default logic with duplicate params +variable_user_print_state_changed_extension : '' ; Executed after default logic with duplicate params +variable_user_mmu_event_extension : '' ; Executed after default logic with duplicate params + +# Maintenance warning limits (consumption counters) +variable_servo_down_limit : 5000 ; Set to -1 for no limit / disable warning +variable_cutter_blade_limit : 3000 ; Set to -1 for no limit / disable warning + + +# SEQUENCE MACRO - PARKING MOVEMENT AND TOOLCHANGE CONTROL ---------------- +# ███╗ ███╗ ██████╗ ██╗ ██╗███████╗███╗ ███╗███████╗███╗ ██╗████████╗ +# ████╗ ████║██╔═══██╗██║ ██║██╔════╝████╗ ████║██╔════╝████╗ ██║╚══██╔══╝ +# ██╔████╔██║██║ ██║██║ ██║█████╗ ██╔████╔██║█████╗ ██╔██╗ ██║ ██║ +# ██║╚██╔╝██║██║ ██║╚██╗ ██╔╝██╔══╝ ██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║ +# ██║ ╚═╝ ██║╚██████╔╝ ╚████╔╝ ███████╗██║ ╚═╝ ██║███████╗██║ ╚████║ ██║ +# ╚═╝ ╚═╝ ╚═════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝ +# Configure carefully if you 'enable_park: True' +# (base/mmu_sequence.cfg) +# +[gcode_macro _MMU_SEQUENCE_VARS] +description: Happy Hare sequence macro configuration variables +gcode: # Leave empty + +# Parking and movement controls: +# Happy Hare defines 7 operations that may require parking. You can specify +# whether to park for each of those operations both during a print and +# standalone (not printing) with Happy Hare or when HH is disabled: +# +# enable_park_printing +# This is a list of the operations that should result in toolhead parking +# while in a print. There are really two main starting points from which +# you can customize. If using the slicer to form tips (and toolchange is +# over the wipetower) you don't want to park on "toolchange" but you would +# want to on "runout" which is a forced toolchange unknown by the slicer. +# Typically you would also want to park at least on pause, cancel and +# complete if not done elsewhere +# +# enabled_park_standalone +# List of the operations that should result in toolhead parking when not +# printing, for example, just manipulating the MMU manually or via +# Klipperscreen. Really it is up to you to choose based on personal +# workflow preferences but this defaults to just 'pause,cancel' +# (i.e. disabled for toolchange) +# +# enabled_park_disabled +# List of the operations that should result in toolhead parking when MMU is +# disabled (MMU ENABLE=0) and using Happy Hare client macros. Note that only +# pause and cancel can occur in this mode and would typically be enabled +# +# The operations are as follows: +# toolchange - normal toolchange initiated with Tx or MMU_CHANGE_TOOL command +# runout - when a forced toolchange occurs as a result of runout +# load - individual MMU_LOAD operation +# unload - individual MMU_UNLOAD/MMU_EJECT operation +# complete - when print is complete (Happy Hare enabled) +# pause - a regular klipper PAUSE +# cancel - a regular klipper CANCEL_PRINT +# +# It is possible to call the parking macro manually in this form should you wish +# to include in your macros. +# +# _MMU_PARK FORCE_PARK=1 X=10 Y=10 Z_HOP=5 +# +# restore_xy_pos +# Controls where the toolhead (x,y) is returned to after an operation that +# invokes a parking move: +# last - return to original position before park (frequently the default) +# next - return to next print position if possible else last logic will be applied. +# In print this reduces dwell time at the last position reducing blobbing +# and unnecessary movement. Only applied to "toolchange" operation +# none - the toolhead is left wherever it ends up after change. In a print the +# next gcode command will restore toolhead x,y position +# +# Notes: +# - The starting z-height will always be restored, thus the different between 'next' +# and 'none' is the z-height at which the (x,y) move occurs and the location of +# of any un-retract +# - The default parking logic is a straight line move to the 'park_*' position. +# To implement fancy movement and control you can specify your own +# 'user_park_move_macro' to use instead of default straight line move +# - x,y parking coordinates can be negative if your printer can handle it +# +# Retraction can be used to optimize stringing and blobs that can occur when +# changing tools and are active only during a print. +# IMPORTANT: For toolchanging the config order would be: +# 1. In mmu_parameters.cfg configure extruder dimensions like +# 'toolhead_extruder_to_nozzle',etc. These are based on geometry. +# 2. In mmu_parameters.cfg tweak 'toolhead_ooze_reduction' only if necessary +# so that filament _just_ appears at the nozzle on load +# 3. Only then, adjust retraction to control stringing and blobs when +# changing tool in a print +variable_enable_park_printing : 'toolchange,runout,load,unload,complete,pause,cancel' ; Empty '' to disable parking +variable_enable_park_standalone : 'toolchange,load,unload,pause,cancel' ; Empty '' to disable parking +variable_enable_park_disabled : 'pause,cancel' ; Empty '' to disable parking + +variable_min_toolchange_z : 1.0 ; The absolute minimum safety floor (z-height) for ALL parking moves + +# These specify the parking location, z_hop and retraction for all enabled operation +# types. Each must be 5 values: +# x_coord, y_coord, z_hop(delta), z_hop_ramp, retraction length +# Use -999,-999 for no x,y move (you can just have z_hop). Use 0 for no z_hop +# The z_hop ramp is the horizontal distance in mm to travel during the lift. The +# direction is automatic and only applied if lifting the first time from print. +# This move is useful to help break the filament "string" +variable_park_toolchange : -999, -999, 1, 5, 2 ; x,y,z-hop,z_hop_ramp,retract for "toolchange" operations (toolchange,load,unload) +variable_park_runout : -999, -999, 1, 5, 2 ; x,y,z-hop,z_hop_ramp,retract +variable_park_pause : 50, 50, 5, 0, 2 ; x,y,z-hop,z_hop_ramp,retract (park position when mmu error occurs) +variable_park_cancel : -999, -999, 10, 0, 5 ; x,y,z-hop,z_hop_ramp,retract +variable_park_complete : 50, 50, 10, 0, 5 ; x,y,z-hop,z_hop_ramp,retract + +# For toolchange operations, this allows to you to specify additional parking moves +# at various stages of the toolchange. Each must have 3 values: +# x_coord, y_coord, z_hop(delta) +# Use -999,-999,0 for no movement at that stage (no-op). +# All movement will be at the established movement plane (z-height) +variable_pre_unload_position : -999, -999, 0 ; x,y,z-hop position before unloading starts +variable_post_form_tip_position : -999, -999, 0 ; x,y,z-hop position after form/cut tip on unload +variable_pre_load_position : -999, -999, 0 ; x,y,z-hop position before loading starts + +variable_restore_xy_pos : "last" ; last|next|none - What x,y position the toolhead should travel to after a "toolchange" + +variable_park_travel_speed : 200 ; Speed for any travel movement XY(Z) in mm/s +variable_park_lift_speed : 15 ; Z-only travel speed in mm/s +variable_retract_speed : 30 ; Speed of the retract move in mm/s +variable_unretract_speed : 30 ; Speed of the unretract move in mm/s + +# ADVANCED: Normally x,y moves default to 'G1 X Y' to park position. This allows +# you to create exotic movements. Macro will be provided the following parameters: +# YOUR_MOVE_MACRO X= Y= F= +# when restoring the from parked postion the same macro is called but passed a RESTORE=1 parameter, along with co-ordinates to restore to +# YOUR_MOVE_MACRO RESTORE=1 X= Y= F= +variable_user_park_move_macro : '' ; Executed instead of default 'G1 X Y move' to park position + +variable_auto_home : True ; True = automatically home if necessary, False = disable +variable_timelapse : False ; True = take frame snapshot after load, False = disable + +# Instead of completely defining your your own macros you can can extend functionality +# of default sequence macros by adding a command (or call to your gcode macro) +variable_user_mmu_error_extension : '' ; Executed after default logic when mmu error condition occurs +variable_user_pre_unload_extension : '' ; Executed after default logic +variable_user_post_form_tip_extension : '' ; Executed after default logic +variable_user_post_unload_extension : '' ; Executed after default logic +variable_user_pre_load_extension : '' ; Executed after default logic +variable_user_post_load_extension : '' ; Executed after default logic but before restoring toolhead position + + +# CUT_TIP ----------------------------------------------------------------- +# ██████╗██╗ ██╗████████╗ ████████╗██╗██████╗ +# ██╔════╝██║ ██║╚══██╔══╝ ╚══██╔══╝██║██╔══██╗ +# ██║ ██║ ██║ ██║ ██║ ██║██████╔╝ +# ██║ ██║ ██║ ██║ ██║ ██║██╔═══╝ +# ╚██████╗╚██████╔╝ ██║ ██║ ██║██║ +# ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ +# Don't need to configure if using tip forming +# (base/mmu_cut_tip.cfg) +# +[gcode_macro _MMU_CUT_TIP_VARS] +description: Happy Hare toolhead tip cutting macro configuration variables +gcode: # Leave empty + +# Whether the toolhead tip cutting macro will return toolhead to initial position +# after the cut is complete. If using parking logic it is better to disable this +variable_restore_position : False ; True = return to initial position, False = don't return + +# Distance from the internal nozzle tip to the cutting blade. This dimension +# is based on your toolhead and should not be used for tuning +# Note: If you have a toolhead sensor this variable can be automatically determined! +# Read https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing +variable_blade_pos : 37.5 ; TUNE ME: Distance in mm from internal nozzle tip + +# Distance to retract prior to making the cut, measured from the internal nozzle +# tip. This reduces wasted filament (left behind in extruder) but might cause a +# clog if set too large. This must be less than 'blade_pos' +# Note: the residual filament left in nozzle ('toolhead_ooze_reduction') is +# subtracted from this value so make sure toolhead is calibrated +variable_retract_length : 32.5 ; TUNE ME: 5mm less than 'blade_pos' is a good starting point + +# Whether to perform a simple tip forming move after the initial retraction +# Enabling this adds gives some additional cooling time of molten filament and +# may help avoid potential clogging on some hotends +variable_simple_tip_forming : True ; True = Perform simple tip forming, False = skip + +# Change to X and Y stepper current during the cut operation. Technically any stepper +# current can be modified by adding the stepper name to the list. Be careful not to +# overload your steppers but generally up to 150% is safe +variable_cut_axis_steppers : 'stepper_x, stepper_y' ; Comma separated list of stepper names to increase current for cutting +variable_cut_stepper_current : 100 ; % of stepper current to use for cutting motion (100 to disable) + +# This should be the position of the toolhead where the cutter arm just +# lightly touches the depressor pin +variable_cutting_axis : "x" ; "x" or "y". Determines cut direction (axis) during cut motion, used for park distance +variable_pin_loc_xy : 14, 250 ; x,y coordinates of depressor pin + +# This distance is added (or subtracted if negative) to "pin_loc_x" or "pin_loc_y" depending on the 'cutting_axis' +# This is used to determine the starting position and to create a small safety distance that aids in generating momentum. +# For example, Filametrix cuts along X axis towards Xmin requiring a positive value, whereas A4T-A4C cuts on Y axis towards +# Ymax requring a negative number +variable_pin_park_dist : 5.0 ; Distance in mm (+ve or -ve) from 'variable_pin_loc_xy' + +# Position of the toolhead when the cutter is fully compressed. Should leave a small headroom from the +# extremes of your printer edges (e.g. it should be a bit larger than 0, or whatever Xmin is) to avoid +# banging the toolhead or gantry. Typically x position will match x in pin_loc_xy if cutting in y direction +# or y position will match y in pin_loc_xy if cutting in x direction, but diagonal cuts are possible +variable_pin_loc_compressed_xy : 0.5, 250 ; x,y coordinates of fully depressed location + +# Retract length and speed after the cut so that the cutter blade doesn't +# get stuck on return to origin position +variable_rip_length : 1.0 ; Distance in mm to retract to aid lever decompression (>= 0) +variable_rip_speed : 3 ; Speed mm/s + +# Pushback of the remaining tip from the cold end into the hotend. This does +# not have to push back all the way, just sufficient to ensure filament fragment +# stays in hot end and the "nail head" of the cut is pushed back past the +# PTFE/metal junction so it cannot cause clogging problems on future loads. +# Cannot be larger than 'retract_length' - `toolhead_ooze_reduction` +variable_pushback_length : 15.0 ; TUNE ME: PTFE tube length + 3mm is good starting point +variable_pushback_dwell_time : 0 ; Time in ms to dwell after the pushback + +# Speed related settings for tip cutting +# Note that if the cut speed is too fast, the steppers can lose steps. +# Therefore, for a cut: +# - We first make a fast move to accumulate some momentum and get the cut +# blade to the initial contact with the filament +# - We then make a slow move for the actual cut to happen +variable_travel_speed : 150 ; Speed mm/s +variable_cut_fast_move_speed : 32 ; Speed mm/s +variable_cut_slow_move_speed : 8 ; Speed mm/s +variable_evacuate_speed : 150 ; Speed mm/s +variable_cut_dwell_time : 50 ; Time in ms to dwell at the cut point +variable_cut_fast_move_fraction : 1.0 ; Fraction of the move that uses fast move +variable_extruder_move_speed : 25 ; Speed mm/s for all extruder movement + +# Safety margin for fast vs slow travel. When traveling to the pin location +# we make a safer but longer move if we are closer to the pin than this +# specified margin. Usually setting these to the size of the toolhead +# (plus a small margin) should be good enough +variable_safe_margin_xy : 30, 30 ; Approx toolhead width +5mm, height +5mm) + +# If gantry servo option is installed, enable the servo and set up and down +# angle positions +variable_gantry_servo_enabled : False ; True = enabled, False = disabled +variable_gantry_servo_down_angle: 55 ; Angle for when pin is deployed +variable_gantry_servo_up_angle : 180 ; Angle for when pin is retracted + + +# FORM_TIP ---------------------------------------------------------------- +# ███████╗ ██████╗ ██████╗ ███╗ ███╗ ████████╗██╗██████╗ +# ██╔════╝██╔═══██╗██╔══██╗████╗ ████║ ╚══██╔══╝██║██╔══██╗ +# █████╗ ██║ ██║██████╔╝██╔████╔██║ ██║ ██║██████╔╝ +# ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║ ██║ ██║██╔═══╝ +# ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║ ██║ ██║██║ +# ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ +# Don't need to configure if using tip cutting +# (base/mmu_form_tip.cfg) +# +[gcode_macro _MMU_FORM_TIP_VARS] +description: Happy Hare tip forming macro configuration variables +gcode: # Leave empty + +# Step 1 - Ramming +# Ramming is the initial squeeze of filament prior to cooling moves and is +# described in terms of total volume and progression of squeeze intensity +# printing/standalone. This can be separately controlled when printing or +# standalone +variable_ramming_volume : 0 ; Volume in mm^3, 0 = disabled (optionally let slicer do it) +variable_ramming_volume_standalone : 0 ; Volume in mm^3, 0 = disabled + +# Optionally set for temperature change (reduction). The wait will occur +# before nozzle separation if 'use_fast_skinnydip: False' else after cooling +# moves. Temperature will be restored after tip creation is complete +variable_toolchange_temp : 0 ; 0 = don't change temp, else temp to set +variable_toolchange_fan_assist : False ; Whether to use part cooling fan for quicker temp change +variable_toolchange_fan_name : '' ; Define the part fan name if not default [fan] e.g "fan_generic fan0" +variable_toolchange_fan_speed : 50 ; Fan speed % if using fan_assist enabled + +# Step 2 - Nozzle Separation +# The filament is then quickly separated from the meltzone by a fast movement +# before then slowing to travel the remaining distance to cooling tube. The +# initial fast movement should be as fast as extruder can comfortably perform. +# A good starting point# for slower move is unloading_speed_start/cooling_moves. +# Too fast a slower movement can lead to excessively long tips or hairs +variable_unloading_speed_start : 80 ; Speed in mm/s for initial fast movement +variable_unloading_speed : 18 ; Speed in mm/s for slow move to cooling zone + +# Step 3 - Cooling Moves +# The cooling move allows the filament to harden while constantly moving back +# and forth in the cooling tube portion of the extruder to prevent a bulbous +# tip forming. The cooling tube position is measured from the internal nozzle +# to just past the top of the heater block (often it is beneficial to add a +# couple of mm to ensure the tip is in the cooling section. The cooling tube +# length is then the distance from here to top of heatsink (this is the length +# length of the cooling moves). The final cooling move is a fast movement to +# break the string formed. +variable_cooling_tube_position : 35 ; Start of cooling tube. DragonST:35, DragonHF:30, Mosquito:30, Revo:35, RapidoHF:27 +variable_cooling_tube_length : 10 ; Movement length. DragonST:15, DragonHF:10, Mosquito:20, Revo:10, RapidoHF:10 +variable_initial_cooling_speed : 10 ; Initial slow movement (mm/s) to solidify tip and cool string if formed +variable_final_cooling_speed : 50 ; Fast movement (mm/s) Too fast: tip deformation on eject, Too Slow: long string/no separation +variable_cooling_moves : 4 ; Number of back and forth cooling moves to make (2-4 is a good start) + +# Step 4 - Skinnydip +# Skinnydip is an advanced final move that may have benefit with some +# material like PLA to burn off persistent very fine hairs. To work the +# depth of insertion is critical (start with it disabled and tune last) +# For reference the internal nozzle would be at a distance of +# cooling_tube_position + cooling_tube_length, the top of the heater +# block would be cooling_tube_length away. +variable_use_skinnydip : False ; True = enable skinnydip, False = skinnydip move disabled +variable_skinnydip_distance : 30 ; Distance to reinsert filament into hotend starting from end of cooling tube +variable_dip_insertion_speed : 30 ; Medium/Slow insertion speed mm/s - Just long enough to melt the fine hairs, too slow will pull up molten filament +variable_dip_extraction_speed : 70 ; Speed mm/s - Around 2x Insertion speed to prevents forming new hairs +variable_melt_zone_pause : 0 ; Pause if melt zone in ms. Default 0 +variable_cooling_zone_pause : 0 ; Pause if cooling zone after dip in ms. Default 0 +variable_use_fast_skinnydip : False ; False = Skip the toolhead temp change wait during skinnydip move + +# Step 5 - Parking +# Park filament ready to eject +variable_parking_distance : 0 ; Position mm to park the filament at end of tip forming, 0 = leave where filament ends up after tip forming +variable_extruder_eject_speed : 25 ; Speed mm/s used for parking_distance (and final_eject when testing) + + +# PURGE ------------------------------------------------------------------- +# ██████╗ ██╗ ██╗██████╗ ██████╗ ███████╗ +# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██╔════╝ +# ██████╔╝██║ ██║██████╔╝██║ ███╗█████╗ +# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ +# ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ +# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ +# +# Optional reference (bucket) purge. Blobifier if far better but this can be +# used as a basis for custom purge +# (base/mmu_purge.cfg) +# +[gcode_macro _MMU_PURGE_VARS] +description: Happy Hare reference purging macro configuration variables +gcode: # Leave empty + +# Set speed as fast as you can without the extruder skipping steps. Note that +# you can increase the extruder current for purging in mmu_parameters.cfg +variable_extruder_purge_speed : 2 ; Speed in mm/s for purging + + +# CLIENT MACROS ----------------------------------------------------------- +# ██████╗ █████╗ ██╗ ██╗███████╗███████╗ ██████╗ ███████╗███████╗██╗ ██╗███╗ ███╗███████╗ +# ██╔══██╗██╔══██╗██║ ██║██╔════╝██╔════╝ ██╔══██╗██╔════╝██╔════╝██║ ██║████╗ ████║██╔════╝ +# ██████╔╝███████║██║ ██║███████╗█████╗ ██████╔╝█████╗ ███████╗██║ ██║██╔████╔██║█████╗ +# ██╔═══╝ ██╔══██║██║ ██║╚════██║██╔══╝ ██╔══██╗██╔══╝ ╚════██║██║ ██║██║╚██╔╝██║██╔══╝ +# ██║ ██║ ██║╚██████╔╝███████║███████╗ ██║ ██║███████╗███████║╚██████╔╝██║ ╚═╝ ██║███████╗ +# ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ +# If using the recommended PAUSE/RESUME/CANCEL_PRINT macros shipped with +# Happy Hare these variables allow for customization and basic extension +# Note that most parameters are pulled from the "movement" (sequence) +# macro above and thus these are supplemental a +# (optional/client_macros.cfg) +# +[gcode_macro _MMU_CLIENT_VARS] +description: Happy Hare client macro configuration variables +gcode: # Leave empty + +variable_reset_ttg_on_cancel : False ; True/False, Whether reset TTG map if print is canceled +variable_unload_tool_on_cancel : False ; True/False, Whether to unload the tool on cancel + +# You can extend functionality by adding a command (or call to your gcode macro) +variable_user_pause_extension : '' ; Executed after the klipper base pause +variable_user_resume_extension : '' ; Executed before the klipper base resume +variable_user_cancel_extension : '' ; Executed before the klipper base cancel_print + + +########################################################################### +# Tool change macros +# This is automatically created on installation but you can increase or +# reduce this list to match your number of tools in operation +# Note: it is annoying to have to do this but interfaces like Mainsail rely +# on real macro definitions for tools to be visible in the UI +# +{tx_macros} diff --git a/config/base/mmu_parameters.cfg b/config/base/mmu_parameters.cfg new file mode 100644 index 000000000000..090a37147dff --- /dev/null +++ b/config/base/mmu_parameters.cfg @@ -0,0 +1,820 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Main configuration parameters for the klipper module +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# Notes: +# Macro configuration is specified separately in 'mmu_macro_vars.cfg'. +# Full details in https://github.com/moggieuk/Happy-Hare/tree/main/doc/configuration.md +# +[mmu] +happy_hare_version: {happy_hare_version} # Don't mess, used for upgrade detection + +# MMU Hardware Limits -------------------------------------------------------------------------------------------------- +# ██╗ ██╗███╗ ███╗██╗████████╗███████╗ +# ██║ ██║████╗ ████║██║╚══██╔══╝██╔════╝ +# ██║ ██║██╔████╔██║██║ ██║ ███████╗ +# ██║ ██║██║╚██╔╝██║██║ ██║ ╚════██║ +# ███████╗██║██║ ╚═╝ ██║██║ ██║ ███████║ +# ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ +# +# Define the physical limits of your MMU. These settings will be respected regardless of individual speed settings. +# +gear_max_velocity: 300 # Never to be exceeded gear velocity regardless of specific parameters +gear_max_accel: 1500 # Never to be exceeded gear acceleration regardless of specific parameters +selector_max_velocity: 250 # Never to be exceeded selector velocity regardless of specific parameters +selector_max_accel: 1200 # Never to be exceeded selector acceleration regardless of specific parameters + + +# Servo configuration ------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ +# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗ +# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║ +# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║ +# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝ +# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ +# +# Angle of the servo in three named positions +# up = tool is selected and filament is allowed to freely move through gate +# down = to grip filament +# move = ready the servo for selector move (optional - defaults to up) +# V2.4.0 on: These positions are only for initial config they are replaced with calibrated servo positions in 'mmu_vars.cfg' +# +# Note that leaving the servo active when down can stress the electronics and is not recommended with EASY-BRD or ERB board +# unless the 5v power supply has been improved and it is not necessary with standard ERCF builds +# Make sure your hardware is suitable for the job! +# +servo_up_angle: 145 # ERCF: MG90S: 30 ; SAVOX SH0255MG: 140 ; Tradrack: 145 +servo_down_angle: 30 # ERCF: MG90S: 140 ; SAVOX SH0255MG: 30 ; Tradrack: 1 +servo_move_angle: 109 # Optional angle used when selector is moved (defaults to up position) +servo_duration: 0.4 # Duration of PWM burst sent to servo (default non-active mode, automatically turns off) +servo_dwell: 0.5 # Minimum time given to servo to complete movement prior to next move +servo_always_active: 0 # CAUTION - WILL DAMAGE COMMON SERVOS, PLEASE USE AT YOUR OWN RISK: 1=Force servo to always stay active, 0=Release after movement +servo_active_down: 0 # CAUTION - WILL DAMAGE COMMON SERVOS, PLEASE USE AT YOUR OWN RISK: 1=Force servo to stay active when down only, 0=Release after movement +servo_buzz_gear_on_down: 0 # Whether to "buzz" the gear stepper on down to aid engagement + + +# Logging -------------------------------------------------------------------------------------------------------------- +# ██╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ +# ██║ ██╔═══██╗██╔════╝ ██╔════╝ ██║████╗ ██║██╔════╝ +# ██║ ██║ ██║██║ ███╗██║ ███╗██║██╔██╗ ██║██║ ███╗ +# ██║ ██║ ██║██║ ██║██║ ██║██║██║╚██╗██║██║ ██║ +# ███████╗╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝ +# ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# log_level & logfile_level can be set to one of (0 = essential, 1 = info, 2 = debug, 3 = trace, 4 = developer + stepper moves) +# Generally you can keep console logging to a minimal whilst still sending debug output to the mmu.log file +# Increasing the console log level is only really useful during initial setup to save having to constantly open the log file +# Note: that it is not recommended to keep logging at level greater that 2 (debug) if not debugging an issue because +# of the additional overhead +# +log_level: 1 +log_file_level: 2 # Can also be set to -1 to disable log file completely +log_statistics: 1 # 1 to log statistics on every toolchange (default), 0 to disable (but still recorded) +log_visual: 1 # 1 log visual representation of filament, 0 = disable +log_startup_status: 1 # Whether to log tool to gate status on startup, 1 = summary (default), 0 = disable +log_m117_messages: 1 # Whether send toolchange message via M117 to screen + + +# Movement speeds ------------------------------------------------------------------------------------------------------ +# ███████╗██████╗ ███████╗███████╗██████╗ ███████╗ +# ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝ +# ███████╗██████╔╝█████╗ █████╗ ██║ ██║███████╗ +# ╚════██║██╔═══╝ ██╔══╝ ██╔══╝ ██║ ██║╚════██║ +# ███████║██║ ███████╗███████╗██████╔╝███████║ +# ╚══════╝╚═╝ ╚══════╝╚══════╝╚═════╝ ╚══════╝ +# +# Long moves are faster than the small ones and used for the bulk of the bowden movement. You can set two fast load speeds +# depending on whether pulling from the spool or filament buffer (if fitted and not the first time load). This can be helpful +# in allowing faster loading from buffer and slower when pulling from the spool because of the additional friction (prevents +# loosing steps). Unloading speed can be tuning if you have a rewinder system that imposes additional limits. +# NOTE: Encoder cannot keep up much above 450mm/s so make sure 'bowden_apply_correction' is off at very high speeds! +# +gear_from_spool_speed: 80 # mm/s Speed when loading from the spool (for the first time if has_filament_buffer: 1) +gear_from_spool_accel: 100 # Acceleration when loading from spool +gear_from_buffer_speed: 150 # mm/s Speed when loading filament from buffer. Conservative is 100mm/s, Max around 400mm/s +gear_from_buffer_accel: 400 # Normal acceleration when loading filament +gear_unload_speed: 80 # mm/s Use (lower) speed when unloading filament (defaults to "from spool" speed) +gear_unload_accel: 100 # Acceleration when unloading filament (defaults to "from spool" accel) +# +gear_short_move_speed: 80 # mm/s Speed when making short moves (like incremental retracts with encoder) +gear_short_move_accel: 600 # Usually the same as gear_from_buffer_accel (for short movements) +gear_short_move_threshold: 70 # Move distance that controls application of 'short_move' speed/accel +gear_homing_speed: 50 # mm/s Speed of gear stepper only homing moves (e.g. homing to gate or extruder) + +# Speeds of extruder movement. The 'sync' speeds will be used when gear and extruder steppers are moving in sync +# +extruder_load_speed: 16 # mm/s speed of load move inside extruder from homing position to meltzone +extruder_unload_speed: 16 # mm/s speed of unload moves inside of extruder (very initial move from meltzone is 50% of this) +extruder_sync_load_speed: 18 # mm/s speed of synchronized extruder load moves +extruder_sync_unload_speed: 18 # mm/s speed of synchronized extruder unload moves +extruder_homing_speed: 18 # mm/s speed of extruder only homing moves (e.g. to toolhead sensor) + +# Selector movement speeds. (Acceleration is defined by physical MMU limits set above and passed to selector stepper driver) +# +selector_move_speed: 200 # mm/s speed of selector movement (not touch) +selector_homing_speed: 60 # mm/s speed of initial selector homing move (not touch) +selector_touch_speed: 80 # mm/s speed of all touch selector moves (if stallguard configured) + +# Selector touch (stallguard) operation. If stallguard is configured, then this can be used to switch on touch movement which +# can detect blocked filament path and try to recover automatically but it is more difficult to set up +# +selector_touch_enabled: 0 # If selector touch operation configured this can be used to disable it 1=enabled, 0=disabled + +# When Happy Hare calls out to a macro for user customization and for parking moves these settings are applied and the previous +# values automatically restored afterwards. This allows for deterministic movement speed regardless of the starting state. +# +macro_toolhead_max_accel: 0 # Default printer toolhead acceleration applied when macros are run. 0 = use printer max +macro_toolhead_min_cruise_ratio: 0.5 # Default printer cruise ratio applied when macros are run + + +# Gate loading/unloading ----------------------------------------------------------------------------------------------- +# ██████╗ █████╗ ████████╗███████╗ ██╗ ██████╗ █████╗ ██████╗ +# ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██║ ███╗███████║ ██║ █████╗ ██║ ██║ ██║███████║██║ ██║ +# ██║ ██║██╔══██║ ██║ ██╔══╝ ██║ ██║ ██║██╔══██║██║ ██║ +# ╚██████╔╝██║ ██║ ██║ ███████╗ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +# These settings control the loading and unloading filament at the gate which is the parking position inside the MMU. +# Typically this would be switch sensor but you can also use an encoder. Even with encoder the endstop can be a switch +# and the encoder used for move verifcation (see advanced 'gate_endstop_to_encoder' option). Note that the `encoder` +# method, due to the nature of its operation will overshoot a little. This is not a problem in practice because the +# overshoot will simply be compensated for in the subsequent move. A +ve parking distance moves towards the MMU, -ve +# moves back through the endstop towards the toolhead. If the MMU has multiple bowden tubes then it is possible to home +# at the extruder sensor and avoid long bowden moves! +# +# Possible gate_homing_endstop names: +# encoder - Detect filament position using movement of the encoder +# mmu_gate - Use gate endstop +# mmu_gear - Use individual per-gate endstop (type-B MMU's) +# extruder - Use extruder entry sensor (Only for some type-B designs, see [mmu_machine] require_bowden_move setting) +# +gate_homing_endstop: encoder # Name of gate endstop, "encoder" forces use of encoder for parking +gate_homing_max: 70 # Maximum move distance to home (or actual move distance if encoder endstop) +gate_parking_distance: 23 # Parking position relative to homing endstop (-ve value means move forward) +gate_preload_homing_max: 300 # Maximum homing distance to the mmu_gear endstop (if MMU is fitted with one) +gate_preload_parking_distance: -10 # Parking position relative to mmu_gear endstop (-ve value means move forward) +gate_endstop_to_encoder: 10 # Distance between gate endstop and encoder (IF both fitted. +ve if encoder after endstop) +gate_unload_buffer: 50 # Amount to reduce the fast unload so that filament doesn't overshoot when parking +gate_load_retries: 2 # Number of times MMU will attempt to grab the filament on initial load (type-A designs) +gate_autoload: 1 # If pre-gate sensor fitted this controls the automatic loading of the gate +gate_final_eject_distance: 0 # Additional distance to eject filament on MMU_EJECT to clear MMU grip + + +# Bowden tube loading/unloading ---------------------------------------------------------------------------------------- +# ██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ +# ██╔══██╗██╔═══██╗██║ ██║██╔══██╗██╔════╝████╗ ██║ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██████╔╝██║ ██║██║ █╗ ██║██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║███████║██║ ██║ +# ██╔══██╗██║ ██║██║███╗██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██║██║ ██║ +# ██████╔╝╚██████╔╝╚███╔███╔╝██████╔╝███████╗██║ ╚████║ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +bowden_homing_max: 2000 # Maximum attempted bowden move (for calibration). Should be larger than your actual bowden! + +# If you MMU is equiped with an encoder the following options are available: +# +# In addition to different bowden loading speeds for buffer and non-buffered filament it is possible to detect missed +# steps caused by "jerking" on a heavy spool. If bowden correction is enabled Happy Hare will "believe" the encoder +# reading and make correction moves to bring the filament to within the 'bowden_allowable_load_delta' of the end of +# bowden position (this does require a reliable encoder and is not recommended for very high speed loading >350mm/s) +# +bowden_apply_correction: 0 # 1 to enable, 0 disabled +bowden_allowable_load_delta: 20.0 # How close in mm the correction moves will attempt to get to target +# +# This saftey check uses the encoder to verify the filament is free of extruder before the fast bowden movement to +# reduce possibility of grinding filament. If enabled the trigger can be tuned by setting the "error tolerance" which +# represents the fraction of allowable mismatch between actual movement and that seen by encoder. Setting to 50% tolerance +# usually works well. Increasing will make test more tolerant. Value of 100% essentially disables error detection +# +bowden_pre_unload_test: 1 # 1 to check for bowden movement before full pull (slower), 0 don't check (faster) +bowden_pre_unload_error_tolerance: 50 # ADVANCED: tune pre_unload_test + + +# Extruder homing ----------------------------------------------------------------------------------------------------- +# ███████╗██╗ ██╗████████╗ ██╗ ██╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ +# ██╔════╝╚██╗██╔╝╚══██╔══╝ ██║ ██║██╔═══██╗████╗ ████║██║████╗ ██║██╔════╝ +# █████╗ ╚███╔╝ ██║ ███████║██║ ██║██╔████╔██║██║██╔██╗ ██║██║ ███╗ +# ██╔══╝ ██╔██╗ ██║ ██╔══██║██║ ██║██║╚██╔╝██║██║██║╚██╗██║██║ ██║ +# ███████╗██╔╝ ██╗ ██║██╗ ██║ ██║╚██████╔╝██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ +# ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# Happy Hare needs a reference "homing point" close to the extruder from which to accurately complete the loading of +# the toolhead. This homing operation takes place after the fast bowden load and it is anticipated that that load +# operation will leave the filament just shy of the homing point. If using a toolhead sensor this initial extruder +# homing is unnecessary (but can be forced) because the homing will occur inside the extruder for the optimum in accuracy. +# You still should set this homing method because it is also used for the determination and calibration of bowden length. +# +# In addition to an entry sensor "extruder" it is possible for Happy Hare to "feel" for the extruder gear entry +# by colliding with it. This can be done with encoder based collision detection, the compression of the sync-feedback +# (aka buffer) sensor or using "touch" (stallguard) on the gear stepper. Note that encoder collision detection is not +# completely deterministic and you will have to find the sweetspot for your setup by adjusting the TMC current reduction. +# Note that reduced current during collision detection can also prevent unecessary filament griding. +# +# Possible extruder_homing_endtop names: +# filament_compression - If you have a "sync-feedback" sensor with compression switch configured +# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home +# extruder - If you have a "filament entry" endstop configured (Requires 'extruder' endstop) +# Fast bowden load will move to extruder_homing_buffer distance before sensor, then home +# collision - Detect the collision with the extruder gear by monitoring encoder movement (Requires encoder) +# Fast bowden load will move to the extruder gears +# mmu_gear_touch - Use touch detection when the gear stepper hits the extruder (Requires stallguard) +# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home +# none - Don't attempt to home. Only possibiliy if lacking all sensor options +# Fast bowden load will move to the extruder gears. Option is fine if using toolhead sensor +# Note: The homing_endstop will be ignored ("none") if a toolhead sensor is available unless "extruder_force_homing: 1" +# +extruder_homing_max: 80 # Maximum distance to advance in order to attempt to home the extruder +extruder_homing_endstop: collision # Filament homing method/endstop name (fallback if toolhead sensor not available) +extruder_homing_buffer: 25 # Amount to reduce the fast bowden load so filament doesn't overshoot the extruder homing point +extruder_collision_homing_current: 30 # % gear_stepper current (10%-100%) to use when homing to extruder homing (100 to disable) + +# If you have a toolhead sensor it will always be used as a homing point making the homing outside of the extruder +# potentially unnecessary. However you can still force this initial homing step by setting this option in which case +# the filament will home to the extruder and then home to the toolhead sensor in two steps +# +extruder_force_homing: 0 + + +# Toolhead loading and unloading -------------------------------------------------------------------------------------- +# ████████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗███████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ +# ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██║ ██║ ██║██║ ██║██║ ███████║█████╗ ███████║██║ ██║ ██║ ██║ ██║███████║██║ ██║ +# ██║ ██║ ██║██║ ██║██║ ██╔══██║██╔══╝ ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║ ██║ +# ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║███████╗██║ ██║██████╔╝ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +# It is possible to define highly customized loading and unloading sequences, however, unless you have a specialized +# setup it is probably easier to opt for the built-in toolhead loading and unloading sequence which already offers a +# high degree of customization. If you need even more control then edit the _MMU_LOAD_SEQUENCE and _MMU_UNLOAD_SEQUENCE +# macros in mmu_sequence.cfg - but be careful! +# +# An MMU must have a known point at the end of the bowden from which it can precisely load the extruder. Generally this +# will either be the extruder entrance (which is controlled with settings above) or by homing to toolhead sensor. If +# you have toolhead sensor it is past the extruder gear and the driver needs to know the max distance (from end of +# bowden move) to attempt homing +# +toolhead_homing_max: 40 # Maximum distance to advance in order to attempt to home to defined homing endstop + +# IMPORTANT: These next three settings are based on the physical dimensions of your toolhead +# Once a homing position is determined, Happy Hare needs to know the final move distance to the nozzle. There is only +# one correct value for your setup - use 'toolhead_ooze_reduction' (which corresponds to the residual filament left in +# your nozzle) to control excessive oozing on load. See doc for table of proposed values for common configurations. +# +# NOTE: If you have a toolhead sensor you can automate the calculation of these parameters! Read about the +# `MMU_CALIBRATE_TOOLHEAD` command (https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing#---calibrating-toolhead) +# +toolhead_extruder_to_nozzle: 72 # Distance from extruder gears (entrance) to nozzle +toolhead_sensor_to_nozzle: 62 # Distance from toolhead sensor to nozzle (ignored if not fitted) +toolhead_entry_to_extruder: 8 # Distance from extruder "entry" sensor to extruder gears (ignored if not fitted) + +# This setting represents how much residual filament is left behind in the nozzle when filament is removed, it is thus +# used to reduce the extruder loading length and prevent excessive blobbing but also in the calculation of purge volume. +# Note that this value can also be measured with the `MMU_CALIBRATE_TOOLHEAD` procedure +# +toolhead_residual_filament: 0 # Reduction in extruder loading length because of residual filament left behind + +# TUNING: Finally, this is the last resort tuning value to fix blobbing. It is expected that this value is NEAR ZERO as +# it represents a further reduction in extruder load length to fix blobbing. If using a wipetower and you experience blobs +# on it, increase this value (reduce the quantity of filament loaded). If you experience gaps, decrease this value. If gaps +# and already at 0 then perhaps the 'toolhead_extruder_to_nozzle' or 'toolhead_residual_filament' settings are incorrect. +# Similarly a value >+5mm also suggests the four settings above are not correct. Also see 'retract' setting in +# 'mmu_macro_vars.cfg' for final in-print ooze tuning. +# +toolhead_ooze_reduction: 0 # Reduction in extruder loading length to prevent ooze (represents filament remaining) + +# Distance added to the extruder unload movement to ensure filament is free of extruder. This adds some degree of tolerance +# to slightly incorrect configuration or extruder slippage. However don't use as an excuse for incorrect toolhead settings +# +toolhead_unload_safety_margin: 10 # Extra movement safety margin (default: 10mm) + +# If not synchronizing gear and extruder and you experience a "false" encoder clog detection immediately after the tool +# change it might be because of a long bowden and/or large internal diameter that causes slack in the filament. This optional +# move will tighten the filament after a load by % of current clog detection length. Gear stepper will run at 50% current +# +toolhead_post_load_tighten: 60 # % of clog detection length, 0 to disable. Ignored if 'sync_to_extruder: 1' + +# If synchronizing gear and extruder and you have a sync-feedback "buffer" (switch based or proportional) this setting +# determines whether to use it to create neutral tension after loading +toolhead_post_load_tension_adjust: 1 # 1 to enable (recommended), 0 to disable + +# If sync-feedback compression sensor is available this test will ensure the filament passes the extruder entry by checking +# for neutral tension when moving filament with just the extruder. Recommended with sprung loaded sync-feedback buffers. +# This is ignored if toolhead sensor is available. +toolhead_entry_tension_test: 1 # 1 to enable (recommended), 0 to disable + +# ADVANCED: Controls the detection of successful extruder load/unload movement and represents the fraction of allowable +# mismatch between actual movement and that seen by encoder. Setting to 100% tolerance effectively turns off checking. +# Some designs of extruder have a short move distance that may not be picked up by encoder and cause false errors. This +# allows masking of those errors. However the error often indicates that your extruder load speed is too high or the +# friction is too high on the filament and in that case masking the error is not a good idea. Try reducing friction +# and lowering speed first! +# +toolhead_move_error_tolerance: 60 + + +# Tip forming --------------------------------------------------------------------------------------------------------- +# ████████╗██╗██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ +# ╚══██╔══╝██║██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ +# ██║ ██║██████╔╝ █████╗ ██║ ██║██████╔╝██╔████╔██║██║██╔██╗ ██║██║ ███╗ +# ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██║ ██║ +# ██║ ██║██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# Tip forming responsibility can be split between slicer (in-print) and standalone macro (not in-print) or forced to always +# be done by Happy Hare's standalone macro. Since you always need the option to form tips without the slicer so it is +# generally easier to completely turn off the slicer, force "standalone" tip forming and tune only in Happy Hare. +# +# When Happy Hare is asked to form a tip it will run the referenced macro. Two are reference examples are provided but +# you can implement your own: +# _MMU_FORM_TIP .. default tip forming similar to popular slicers like Superslicer and Prusaslicer +# _MMU_CUT_TIP .. for Filametrix (originally ERCFv2) or similar style toolhead filament cutting system +# +# NOTE: For MMU located cutting like the optional EREC cutter you should set still this to _MMU_FORM_TIP to build a decent +# tip prior to extraction and cutting after the unload. +# +# Often it is useful to increase the extruder current for the rapid movement to ensure high torque and no skipped steps +# +# If opting for slicer tip forming you MUST configure where the slicer leaves the filament in the extruder since +# there is no way to determine this. This can be ignored if all tip forming is performed by Happy Hare +# +force_form_tip_standalone: 1 # 0 = Slicer in print else standalone, 1 = Always standalone tip forming (TURN SLICER OFF!) +form_tip_macro: _MMU_FORM_TIP # Name of macro to call to perform the tip forming (or cutting) operation +extruder_form_tip_current: 100 # % of extruder current (100%-150%) to use when forming tip (100 to disable) +slicer_tip_park_pos: 0 # This specifies the position of filament in extruder after slicer completes tip forming + + +# Purging ------------------------------------------------------------------------------------------------------------- +# ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ +# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██║████╗ ██║██╔════╝ +# ██████╔╝██║ ██║██████╔╝██║ ███╗██║██╔██╗ ██║██║ ███╗ +# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║ +# ██║ ╚██████╔╝██║ ██║╚██████╔╝██║██║ ╚████║╚██████╔╝ +# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# After a toolchange it is necessary to purge the old filament. Similar to tip forming this can be done by the slicer and/or +# by Happy Hare using an extension like Blobifer. If a purge_macro is defined it will be called when not printing or whenever +# the slicer isn't going to purge (like initial tool load). You can force it to always be called in a print by setting +# force_purge_standalone, but remember to turn off the slicer wipetower +# +# The default is for no (empty) macro so purging will not be done out of a print and thus wipetower. Two options are shipped with +# Happy Hare but you can also build your own custom one: +# _MMU_PURGE .. default purging that just dumps the desired amount of filament (setup correct parking before enabling this!) +# BLOBIFIER .. for excellent Blobifer addon (https://github.com/Dendrowen/Blobifier) +# +# Often it is useful to increase the extruder current for the often rapid puring movement to ensure high torque and no skipped steps +# +force_purge_standalone: 0 # 0 = Slicer wipetower in print else standalone, 1 = Always standalone purging (TURN WIPETOWER OFF!) +purge_macro: # Name of macro to call to perform the standalone purging operation. E.g. BLOBIFIER, _MMU_PURGE +extruder_purge_current: 100 # % of extruder current (100%-150%) to use when purging (100 to disable) + + +# Synchronized gear/extruder movement ---------------------------------------------------------------------------------- +# ███╗ ███╗ ██████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗███╗ ██╗ ██████╗ +# ████╗ ████║██╔═══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██╔════╝╚██╗ ██╔╝████╗ ██║██╔════╝ +# ██╔████╔██║██║ ██║ ██║ ██║ ██║██████╔╝ ███████╗ ╚████╔╝ ██╔██╗ ██║██║ +# ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║██╔══██╗ ╚════██║ ╚██╔╝ ██║╚██╗██║██║ +# ██║ ╚═╝ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║ ███████║ ██║ ██║ ╚████║╚██████╗ +# ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ +# +# This controls whether the extruder and gear steppers are synchronized during printing operations +# If you normally run with maxed out gear stepper current consider reducing it with 'sync_gear_current' +# If equipped with TMC drivers the current of the gear and extruder motors can be controlled to optimize performance. +# This can be useful to control gear stepper temperature when printing with synchronized motor +# +sync_to_extruder: 0 # Gear motor is synchronized to extruder during print +sync_gear_current: 70 # % of gear_stepper current (10%-100%) to use when syncing with extruder during print +sync_form_tip: 0 # Synchronize during standalone tip formation (initial part of unload) +sync_purge: 0 # Synchronize during standalone purging (last part of load) + +# Optionally it is possible to leverage feedback from a "compression/expansion" sensor (aka "buffer") in the bowden +# path from MMU to extruder to ensure that the two motors are kept in sync as viewed by the filament (the signal feedback +# state can be binary supplied by one or two switches: -1 (expanded) and 1 (compressed) of proportional value between +# -1.0 and 1.0. +# +# If only "one half" of the sync-feedback is available (either compression-only or tension-only) then the rotation +# distance is always shifted based on the high/low multipliers, however if both tension and compression are available +# then the rotation distance will autotune to correct setting (recommend you also enable 'autotune_rotation_distance: 1' +# so that values are saved) +# +# Possible buffer setups, forth option for type where neutral is when both sensors are active: +# +# <------maxrange------> <------maxrange------> <------maxrange------> <------maxrange------> +# <--range---> <----range-----> <----range-----> <> range=0 +# |====================| |====================| |====================| |====================| +# ^ ^ ^ ^ ^^ +# compression tension compression-only tension-only +# +sync_feedback_enabled: 0 # Turn off even if sensor is installed and active +sync_feedback_buffer_range: 6 # Travel in "buffer" between compression/tension or one sensor and end (see above) +sync_feedback_buffer_maxrange: 12 # Absolute maximum end-to-end travel (mm) provided by buffer (see above) +sync_feedback_speed_multiplier: 5 # % "twolevel" gear speed delta to keep filament neutral in buffer (recommend 5%) +sync_feedback_boost_multiplier: 3 # % "twolevel" extra gear speed boost for finding initial neutral position (recommend 3%) +sync_feedback_extrude_threshold: 5 # Extruder movement (mm) for updates (keep small but set > retract distance) + +# If defined this forces debugging to a telemetry log file "sync_.jsonl". This is great if trying to tune clog/tangle +# detection or for getting help on the Happy Hare forum. To plot graph of sync-feedback operation, run: +# ~/Happy-Hare/utils/plot_sync_feedback.sh +# +sync_feedback_debug_log: 0 # 0 = disable (normal operation), 1 = enable telemetry log (for debugging) + + +# ESpooler control ----------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ +# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ +# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ +# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ +# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ +# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ +# +# If your MMU has a dc motor (often N20) controlled respooler/assist then how it operates can be controlled with these +# settings. Typically the espooler will be controlled with PWM signal. This will be at the maximum at speeds equal or +# above 'espooler.max_stepper_speed'. The PWM signal will scale downwards towards 0 for slower speeds. The falloff being +# controlled by the 'espooler_speed_exponent' setting according to this formula and allows for non-linear characteristics +# the DC motor (0.5 is a good starting value). +# +# espooler_pwm = (stepper_speed / espooler_max_stepper_speed) ^ {espooler_speed_exponent} +# +# Regardless of h/w configuration you can enable/disable actions with the 'espooler_operations' list. E.g. remove 'play' to +# turn off operation while printing. Options are: +# +# rewind - when filament is being unloaded under MMU control (aka respool) +# assist - when filament is being loaded under MMU control (% of "rewind" speed but with minimum of "print" power) +# print - while printing. Generally set 'espooler_printing_power' to a low percentage just to allow motor to be turned +# freely or set to 0 to enable/allow "burst" assist movements +# +# If using a digitally controlled espooler motor (not PWM) then you should turn off the "print" mode and set +# 'espooler_min_stepper_speed' to prevent "over movement" +# +espooler_min_distance: 30 # Individual stepper movements less than this distance will not active espooler +espooler_max_stepper_speed: 300 # Gear stepper speed at which espooler will be at maximum power +espooler_min_stepper_speed: 0 # Gear stepper speed at which espooler will become inactive (useful for non PWM control) +espooler_speed_exponent: 0.5 # Controls non-linear espooler power relative to stepper speed (see notes) +espooler_assist_reduced_speed: 50 # Control the % of the rewind speed that is applied to assisting load (want rewind to be faster) +espooler_printing_power: 0 # If >0, fixes the % of PWM power while printing. 0=allows burst movement +espooler_operations: rewind, assist, print # List of operational modes (allows disabling even if h/w is configured) +# +# The following burst configuration is used to control the small rotation in the ASSIST direction optionally used +# when in 'print' operation is enabled, 'espooler_printing_power: 0' and is triggered (tension switch or extruder movement). +# It can also be used to loosen filament with 'MMU_ESPOOLER COMMAND=assist BURST=1' +# +espooler_assist_extruder_move_length: 100 # Distance (mm) extruder needs to move between each assist burst +espooler_assist_burst_power: 100 # The % power of the burst move +espooler_assist_burst_duration: 0.4 # The duration of the burst move is seconds +espooler_assist_burst_trigger: 0 # If trigger assist switch is fitted 0=disable, 1=enable +espooler_assist_burst_trigger_max: 3 # If trigger assist switch is fitted this limits the max number of back-to-back advances +# +# The following burst configuration is used to control the small rotation in the REWIND direction optionally used +# when running running the filament drying cycle. The goal is to rotate the spool 60-90 degrees. It can also be +# used to tighten the filament with 'MMU_ESPOOLER COMMAND=rewind BURST=1' +# +espooler_rewind_burst_power: 100 # The % power of the rewind burst move +espooler_rewind_burst_duration: 0.4 # The duration of the rewind burst move is seconds + + +# Heater / Environment Management ------------------------------------------------------------------------------------ +# ██╗ ██╗███████╗ █████╗ ████████╗███████╗██████╗ +# ██║ ██║██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ +# ███████║█████╗ ███████║ ██║ █████╗ ██████╔╝ +# ██╔══██║██╔══╝ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ +# ██║ ██║███████╗██║ ██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +heater_max_temp: 70 # Absolute max heater setting to protect the MMU enclosure construction (adjust to match material) +heater_default_dry_temp: 45 # Default drying temperature if filament type is not matched in drying_data +heater_default_dry_time: 300 # Default drying cycle time in minutes +heater_default_humidity: 25 # Default humidity % goal. Drying will terminate if this value is reached +heater_vent_macro: _MMU_VENT # Name of macro to periodicaly call during drying cycle +heater_vent_interval: 0 # Interval in minutes to call heater_vent_macro during drying cycle, 0=disable venting +heater_rotate_interval: 5 # Interval in minutes to rotate filament (requires eSpooler and filament end attached to spool) + +# Drying data for MMU_HEATER DRY=1 command in form (material type is case insensitive): +# 'filament_type': (temp, drying_time_mins) +# +# (Careful with formatting of this line - reformatting will break upgrade logic) +# +drying_data: { 'pla': (45, 300), 'pla+': (55, 300), 'petg': (60, 300), 'tpu': (55, 300), 'abs': (70, 300), 'abs+': (75, 300), 'asa': (65, 300), 'nylon': (75, 600), 'pc': (75, 600), 'pva': (75, 600), 'hips': (75, 600) } + + +# FlowGuard Clog and Tangle Detection -------------------------------------------------------------------------------- +# ███████╗██╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ +# ██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗ +# █████╗ ██║ ██║ ██║██║ █╗ ██║██║ ███╗██║ ██║███████║██████╔╝██║ ██║ +# ██╔══╝ ██║ ██║ ██║██║███╗██║██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║ +# ██║ ███████╗╚██████╔╝╚███╔███╔╝╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝ +# ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ +# +# Options are available to automatically detects extruder clogs and MMU tangles. Each option works independently and +# can be combined. Flowguard can even discern the difference between an extruder clog and a spool tangle! +# +# Flowguard: This intelligently measures filament tension (only available if sync-feedback buffer is fitted) +# +# Encoder detection: This monitors encoder movement and compares to extruder (only available if encoder is fitted) +# +flowguard_enabled: 1 # 0 = Flowguard protection disabled, 1 = Enabled + +# The flowguard_max_relief is the amount of relief movement (effective mm change in filament length between MMU and extruder) +# that Happy Hare will wait until triggering a clog or runout. A smaller value is more sensitive to triggering. Since the +# relief movement is hightly dependent on filament "spring" in the bowden tube, filament friction, and +# 'sync_feedback_buffer_range', it is generally good to start high and then decrease if a more sensitive trigger is desired. +# Analog proportional (type P) sensors can generally have a much lower value. Increase if you have false triggers. +flowguard_max_relief: 40 + +# Encoder runout/clog/tangle detection watches for movement over either a static or automatically adjusted distance - if +# no encoder movement is seen when the extruder moves this distance runout/ clog/tangle event will be generated. Allowing +# the distance to be adjusted automatically (mode=2) will generally allow for a quicker trigger but use a static length +# (mode=1, set encoder_max_motion) if you get false triggers (see flowguard guide on wiki for more details). +# Note that this feature cannot disinguish between clog or tangle. +flowguard_encoder_mode: 2 # 0 = Disable, 1 = Static length clog detection, 2 = Automatic length clog detection + +# The encoder_max_motion is the absolute max permitted extruder movement without the encoder seeing movement when using +# status mode (mode=1). Smaller values are more sensitive but beware of going too small - slack and friction in the +# bowden may cause gaps in encoder movement. Increase if you have false triggers. +# Note that this value is overriden by any calibrated value stored in 'mmu_vars.cfg' if in automatic mode (mode=2). +flowguard_encoder_max_motion: 20 + + +# Filament Management Options ---------------------------------------------------------------------------------------- +# ███████╗██╗██╗ ███╗ ███╗ ██████╗ ███╗ ███╗████████╗ +# ██╔════╝██║██║ ████╗ ████║██╔════╝ ████╗ ████║╚══██╔══╝ +# █████╗ ██║██║ ██╔████╔██║██║ ███╗██╔████╔██║ ██║ +# ██╔══╝ ██║██║ ██║╚██╔╝██║██║ ██║██║╚██╔╝██║ ██║ +# ██║ ██║███████╗██╗ ██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ +# +# - EndlessSpool feature allows detection of runout on one spool and the automatic mapping of tool to an alternative +# gate (spool). Set to '1', this feature requires clog detection or gate sensor or pre-gate sensors. EndlessSpool +# functionality can optionally be extended to attempt to load an empty gate with 'endless_spool_on_load'. On some MMU +# designs (with linear selector) it can also be configured to eject filament remains to a designated gate rather than +# defaulting to current gate. A custom gate will disable pre-gate runout detection for EndlessSpool because filament +# end must completely pass through the gate for selector to move +# +endless_spool_enabled: 1 # 0 = disable, 1 = enable endless spool +endless_spool_on_load: 0 # 0 = don't apply endless spool on load, 1 = run endless spool if gate is empty +endless_spool_eject_gate: -1 # Which gate to eject the filament remains. -1 = current gate +#endless_spool_groups: # Default EndlessSpool groups (see later in file) +# +# Spoolman support requires you to correctly enable spoolman with moonraker first. If enabled, the gate SpoolId will +# be used to load filament details and color from the spoolman database and Happy Hare will activate/deactivate +# spools as they are used. The enabled variation allows for either the local map or the spoolman map to be the +# source of truth as well as just fetching filament attributes. See this table for explanation: +# +# | Activate/ | Fetch filament attributes | Filament gate | Filament gate | +# spoolman_support | Deactivate | attributes from spoolman | assignment shown | assignment pulled | +# | spool? | based on spool_id? | in spoolman db? | from spoolman db? | +# -----------------+------------+---------------------------+------------------+-------------------+ +# off | no | no | no | no | +# readonly | yes | yes | no | no | +# push | yes | yes | yes | no | +# pull | yes | yes | yes | yes | +# +spoolman_support: off # off = disabled, readonly = enabled, push = local gate map, pull = remote gate map +pending_spool_id_timeout: 20 # Seconds after which this pending spool_id (set with rfid) is voided +# +# Mainsail/Fluid UI can visualize the color of filaments next to the extruder/tool chooser. The color is dynamic and +# can be customized to your choice: +# +# slicer - Color from slicer tool map (what the slicer expects) +# allgates - Color from all the tools in the gate map after running through the TTG map +# gatemap - As per gatemap but hide empty tools +# off - Turns off support +# +# Note: Happy Hare will also add the 'spool_id' variable to the Tx macro if spoolman is enabled +# +t_macro_color: slicer # 'slicer' = default | 'allgates' = mmu | 'gatemap' = mmu without empty gates | 'off' + + +# Print Statistics --------------------------------------------------------------------------------------------------- +# ███████╗████████╗ █████╗ ████████╗███████╗ +# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ +# ███████╗ ██║ ███████║ ██║ ███████╗ +# ╚════██║ ██║ ██╔══██║ ██║ ╚════██║ +# ███████║ ██║ ██║ ██║ ██║ ███████║ +# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ +# +# These parameters determine how print statistic data is shown in the console. This table can show a lot of data, +# probably more than you'd want to see. Below you can enable/disable options to your needs. +# +# +-----------+---------------------+----------------------+----------+ +# | 114(46) | unloading | loading | complete | +# | swaps | pre | - | post | pre | - | post | swap | +# +-----------+------+-------+------+------+-------+-------+----------+ +# | all time | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | +# | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | +# | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | +# | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | +# | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | +# +-----------+------+-------+------+------+-------+-------+----------+ +# Note: Only formats correctly on Python3 +# +# Comma separated list of desired columns +# Options: pre_unload, form_tip, unload, post_unload, pre_load, load, purge, post_load, total +console_stat_columns: unload, load, post_load, total + +# Comma separated list of rows. The order determines the order in which they're shown. +# Options: total, total_average, job, job_average, last +console_stat_rows: total, total_average, job, job_average, last + +# How you'd want to see the state of the gates and how they're performing +# string - poor, good, perfect, etc.. +# percentage - rate of success +# emoticon - fun sad to happy faces (python3 only) +console_gate_stat: emoticon + +# Always display the full statistics table +console_always_output_full: 1 # 1 = Show full table, 0 = Only show totals out of print + + +# Calibration and autotune ------------------------------------------------------------------------------------------- +# ██████╗ █████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ +# ██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ +# ██║ ███████║██║ ██║██████╔╝██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ +# ██║ ██╔══██║██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ +# ╚██████╗██║ ██║███████╗██║██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ +# ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ +# +# These are auto calibration/tuning settings that can be used to ease initial setup and/or to tune calibration over +# time based on measured telemetry. Whether these auto-tuning features are available depends on MMU design and +# configured sensors (explained below). The setting will be ignored if the required sensors are not available but if +# they can operate they will suppress the normal calibration warnings (MMU_STATUS can still be used to view them). +# Note that these are initially set by the installer to recommended values +# +# autocal_bowden_length - the calibrated bowden length will be established on first load. It can also be set +# manually or reset with MMU_CALIBRATE_BOWDEN. Best results require the use of +# sync-feedback-compression or extruder sensor but gear-touch or encoder will also work. +# 'extruder_homing_endstop' cannot be 'none' +# autotune_bowden_length - Once calibrated this setting will tune the bowden distance over time. Works best with +# toolhead sensor +# skip_cal_rotation_distance - This will rely on installed default value (although it can still be calibrated). Usually +# a good choice if autotune is enabled +# autotune_rotation_distance - Requires sync-feedback sensor (aka "buffer") or calibrated encoder. If set then either the +# "autotuner" (sync-feedback buffer) or encoder telemetry will be used to adjust the +# persisted gear rotation distance. +# skip_cal_encoder - Will rely on installed default value (although it can still be calibrated). +# Not recommended but allows for easier initial setup especially when 'autotune_encoder' +# is enabled. +# autotune_encoder - NOT IMPLEMENTED YET. Soon! +# +autocal_bowden_length: 1 # Automated bowden length calibration. 1=automatic, 0=manual/off +autotune_bowden_length: 1 # Automated bowden length tuning. 1=on, 0=off +skip_cal_rotation_distance: 0 # Skip rotation distance calibration (MMU_CALIBRATE_GEAR), 1=skip, 0=require +autotune_rotation_distance: 0 # Automated gate calibration/tuning. 1=automatic, 0=manual/off +skip_cal_encoder: 0 # Skip encoder calibration (MMU_CALIBRATE_ENCODER), 1=skip, 0=require +autotune_encoder: 0 # Automated encoder tuning. 1=automatic, 0=manual/off + + +# Miscellaneous, but you should review ------------------------------------------------------------------------------- +# ███╗ ███╗██╗███████╗ ██████╗ +# ████╗ ████║██║██╔════╝██╔════╝ +# ██╔████╔██║██║███████╗██║ +# ██║╚██╔╝██║██║╚════██║██║ +# ██║ ╚═╝ ██║██║███████║╚██████╗ +# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ +# +# Important you verify these work for you setup/workflow. Temperature and timeouts +# +timeout_pause: 72000 # Idle time out (printer shuts down) in seconds used when in MMU pause state +disable_heater: 600 # Delay in seconds after which the hotend heater is disabled in the MMU_PAUSE state +default_extruder_temp: 200 # Default temperature for performing swaps and forming tips when not in print (overridden by gate map) +extruder_temp_variance: 2 # When waiting for extruder temperature this is the +/- permissible variance in degrees (>= 1) +# +# Other workflow options +# +startup_home_if_unloaded: 0 # 1 = force mmu homing on startup if unloaded, 0 = do nothing +startup_reset_ttg_map: 0 # 1 = reset TTG map on startup, 0 = do nothing +show_error_dialog: 1 # 1 = show pop-up dialog in addition to console message, 0 = show error in console +preload_attempts: 5 # How many "grabbing" attempts are made to pick up the filament with preload feature +strict_filament_recovery: 0 # If enabled with MMU with toolhead sensor, this will cause filament position recovery to + # perform extra moves to look for filament trapped in the space after extruder but before sensor +filament_recovery_on_pause: 1 # 1 = Run a quick check to determine current filament position on pause/error, 0 = disable +retry_tool_change_on_error: 0 # Whether to automatically retry a failed tool change. If enabled Happy Hare will perform + # the equivalent of 'MMU_RECOVER' + 'Tx' commands which usually is all that is necessary + # to recover. Note that enabling this can mask problems with your MMU +bypass_autoload: 1 # If extruder sensor fitted this controls the automatic loading of extruder for bypass operation +has_filament_buffer: 1 # Whether the MMU has a filament buffer. Set to 0 if using Filamentalist or DC eSpooler, etc +# +# Advanced options. Don't mess unless you fully understand. Read documentation. +# +encoder_move_validation: 1 # ADVANCED: 1 = Normally Encoder validates move distances are within given tolerance + # 0 = Validation is disabled (eliminates slight pause between moves but less safe) +print_start_detection: 1 # ADVANCED: Enabled for Happy Hare to automatically detect start and end of print and call + # ADVANCED: MMU_PRINT_START and MMU_PRINT_END automatically. Harmless to leave enabled but can disable + # if you think it is causing problems and known START/END is covered in your macros +extruder: extruder # ADVANCED: Name of the toolhead extruder that MMU is using +gcode_load_sequence: 0 # VERY ADVANCED: Gcode loading sequence 1=enabled, 0=internal logic (default) +gcode_unload_sequence: 0 # VERY ADVANCED: Gcode unloading sequence, 1=enabled, 0=internal logic (default) + + +# ADVANCED: Klipper tuning ------------------------------------------------------------------------------------------- +# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ +# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ +# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ +# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ +# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +# Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing +# operations especially so with CANbus connected MCUs. Happy Hare uses many homing moves for reliable extruder loading +# and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error +# +update_trsync: 0 # 1 = Increase TRSYNC_TIMEOUT, 0 = Leave the klipper default +# +# Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms timeout will kill +# the print. Since it seems to occur only on homing moves they can be safely retried to workaround. This has been +# working well in practice +canbus_comms_retries: 3 # Number of retries. Recommend the default of 3. +# +# Older neopixels have very finicky timing and can generate lots of "Unable to obtain 'neopixel_result' response" +# errors in klippy.log. An often cited workaround is to increase BIT_MAX_TIME in neopixel.py. This option does that +# automatically for you to save dirtying klipper +update_bit_max_time: 1 # 1 = Increase BIT_MAX_TIME, 0 = Leave the klipper default +# +# BTT ViViD used a AHT30 sensor. If you are using an older klipper you may not have this sensor available. If so, use +# AHT10 and set this to 1 to convert AHT30 commands to AHT10 +update_aht10_commands: 0 # 1 = Config AHT10 for BTT ViViD heater sensor on older klipper, 0 = Leave the klipper default + + +# ADVANCED: MMU macro overrides --- ONLY SET IF YOU'RE COMFORTABLE WITH KLIPPER MACROS ------------------------------- +# ███╗ ███╗ █████╗ ██████╗██████╗ ██████╗ ███████╗ +# ████╗ ████║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔════╝ +# ██╔████╔██║███████║██║ ██████╔╝██║ ██║███████╗ +# ██║╚██╔╝██║██╔══██║██║ ██╔══██╗██║ ██║╚════██║ +# ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║╚██████╔╝███████║ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ +# +# 'pause_macro' defines what macro to call on MMU error (must put printer in paused state) +# Other macros are detailed in 'mmu_sequence.cfg' +# Also see form_tip_macro in Tip Forming section and purge_macro in Purging section +# +pause_macro: PAUSE # What macro to call to pause the print +action_changed_macro: _MMU_ACTION_CHANGED # Called when action (printer.mmu.action) changes +print_state_changed_macro: _MMU_PRINT_STATE_CHANGED # Called when print state (printer.mmu.print_state) changes +mmu_event_macro: _MMU_EVENT # Called on useful MMU events +pre_unload_macro: _MMU_PRE_UNLOAD # Called before starting the unload +post_form_tip_macro: _MMU_POST_FORM_TIP # Called immediately after tip forming +post_unload_macro: _MMU_POST_UNLOAD # Called after unload completes +pre_load_macro: _MMU_PRE_LOAD # Called before starting the load +post_load_macro: _MMU_POST_LOAD # Called after the load is complete +unload_sequence_macro: _MMU_UNLOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_unload_sequence' +load_sequence_macro: _MMU_LOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_load_sequence' + + +# ADVANCED: See documentation for use of these ----------------------------------------------------------------------- +# ██████╗ ███████╗███████╗███████╗████████╗ ██████╗ ███████╗███████╗███████╗ +# ██╔══██╗██╔════╝██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██╔════╝ +# ██████╔╝█████╗ ███████╗█████╗ ██║ ██║ ██║█████╗ █████╗ ███████╗ +# ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║ ██║ ██║██╔══╝ ██╔══╝ ╚════██║ +# ██║ ██║███████╗███████║███████╗ ██║ ██████╔╝███████╗██║ ███████║ +# ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ +# +# These are the values that the various "RESET" commands will reset too rather than the built-in defaults. The lenght +# of the lists must match the number of gates on your MMU +# +# e.g. MMU_GATE_MAP RESET=1 - will use all the 'gate_XXX' values +# MMU_TTG_MAP RESET=1 - will use the 'tool_to_gate_map' +# MMU_ENDLESS_SPOOL_GROUPS RESET=1 - will use the 'endless_spool_groups' +# +# Gate: #0 #1 #2 #3 #4 #5 #6 #7 #8 +#gate_status: 1, 0, 1, 2, 2, -1, -1, 0, 1 +#gate_filament_name: one, two, three, four, five, six, seven, eight, nine +#gate_material: PLA, ABS, ABS, ABS+, PLA, PLA, PETG, TPU, ABS +#gate_color: red, black, yellow, green, blue, indigo, ffffff, grey, black +#gate_temperature: 210, 240, 235, 245, 210, 200, 215, 240, 240 +#gate_spool_id: 3, 2, 1, 4, 5, 6, 7, -1, 9 +#gate_speed_override: 100, 100, 100, 100, 100, 100, 100, 50, 100 +#endless_spool_groups: 0, 1, 2, 1, 0, 0, 3, 4, 1 +# +# Tool: T0 T1 T2 T3 T4 T5 T6 T7 T8 +#tool_to_gate_map: 0, 1, 2, 3, 4, 5, 6, 7, 8 + + +# ADVANCED/CUSTOM MMU: See documentation for use of these ------------------------------------------------------------ +# ██████╗██╗ ██╗███████╗████████╗ ██████╗ ███╗ ███╗ ███╗ ███╗███╗ ███╗██╗ ██╗ +# ██╔════╝██║ ██║██╔════╝╚══██╔══╝██╔═══██╗████╗ ████║ ████╗ ████║████╗ ████║██║ ██║ +# ██║ ██║ ██║███████╗ ██║ ██║ ██║██╔████╔██║ ██╔████╔██║██╔████╔██║██║ ██║ +# ██║ ██║ ██║╚════██║ ██║ ██║ ██║██║╚██╔╝██║ ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ +# ╚██████╗╚██████╔╝███████║ ██║ ╚██████╔╝██║ ╚═╝ ██║ ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ +# ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ +# +# Normally all these settings are set based on your choice of 'mmu_vendor' and 'mmu_version' in mmu_hardware.cfg, but they +# can be overridden. If you have selected a vendor of "Other" and your MMU has a selector you must set these CAD based +# dimensions else you will get arbitrary defaults. You may also need to set additional attributes in '[mmu_machine]' +# section of mmu_hardware.cfg. +# +#cad_gate0_pos: 4.2 # Approximate distance from endstop to first gate. Used for rough calibration only +#cad_gate_width: 21.0 # Width of each gate +#cad_bypass_offset: 0 # Distance from limit of travel back to the bypass (e.g. ERCF v2.0) +#cad_last_gate_offset: 2.0 # Distance from limit of travel back to last gate +#cad_selector_tolerance: 10.0 # How much extra selector movement to allow for calibration +#cad_gate_directions = [1, 1, 0, 0] # Directions of gear depending on gate (3DChameleon) +#cad_release_gates = [2, 3, 0, 1] # Gate to move to when releasing filament (3DChameleon) diff --git a/config/base/mmu_parameters.cfg.rs b/config/base/mmu_parameters.cfg.rs new file mode 100644 index 000000000000..37f4b17f6122 --- /dev/null +++ b/config/base/mmu_parameters.cfg.rs @@ -0,0 +1,795 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# Template file for MMU's with Selector Stepper but no servo (Type-A designs like 3DChameleon) +# This file omits servo parts of the configuration +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Main configuration parameters for the klipper module +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# Notes: +# Macro configuration is specified separately in 'mmu_macro_vars.cfg'. +# Full details in https://github.com/moggieuk/Happy-Hare/tree/main/doc/configuration.md +# +[mmu] +happy_hare_version: {happy_hare_version} # Don't mess, used for upgrade detection + +# MMU Hardware Limits -------------------------------------------------------------------------------------------------- +# ██╗ ██╗███╗ ███╗██╗████████╗███████╗ +# ██║ ██║████╗ ████║██║╚══██╔══╝██╔════╝ +# ██║ ██║██╔████╔██║██║ ██║ ███████╗ +# ██║ ██║██║╚██╔╝██║██║ ██║ ╚════██║ +# ███████╗██║██║ ╚═╝ ██║██║ ██║ ███████║ +# ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ +# +# Define the physical limits of your MMU. These settings will be respected regardless of individual speed settings. +# +gear_max_velocity: 300 # Never to be exceeded gear velocity regardless of specific parameters +gear_max_accel: 1500 # Never to be exceeded gear acceleration regardless of specific parameters +selector_max_velocity: 250 # Never to be exceeded selector velocity regardless of specific parameters +selector_max_accel: 1200 # Never to be exceeded selector acceleration regardless of specific parameters + + +# Logging -------------------------------------------------------------------------------------------------------------- +# ██╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ +# ██║ ██╔═══██╗██╔════╝ ██╔════╝ ██║████╗ ██║██╔════╝ +# ██║ ██║ ██║██║ ███╗██║ ███╗██║██╔██╗ ██║██║ ███╗ +# ██║ ██║ ██║██║ ██║██║ ██║██║██║╚██╗██║██║ ██║ +# ███████╗╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝ +# ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# log_level & logfile_level can be set to one of (0 = essential, 1 = info, 2 = debug, 3 = trace, 4 = stepper moves) +# Generally you can keep console logging to a minimal whilst still sending debug output to the mmu.log file +# Increasing the console log level is only really useful during initial setup to save having to constantly open the log file +# Note: that it is not recommended to keep logging at level greater that 2 (debug) if not debugging an issue because +# of the additional overhead +# +log_level: 1 +log_file_level: 2 # Can also be set to -1 to disable log file completely +log_statistics: 1 # 1 to log statistics on every toolchange (default), 0 to disable (but still recorded) +log_visual: 1 # 1 log visual representation of filament, 0 = disable +log_startup_status: 1 # Whether to log tool to gate status on startup, 1 = summary (default), 0 = disable +log_m117_messages: 1 # Whether send toolchange message via M117 to screen + + +# Movement speeds ------------------------------------------------------------------------------------------------------ +# ███████╗██████╗ ███████╗███████╗██████╗ ███████╗ +# ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝ +# ███████╗██████╔╝█████╗ █████╗ ██║ ██║███████╗ +# ╚════██║██╔═══╝ ██╔══╝ ██╔══╝ ██║ ██║╚════██║ +# ███████║██║ ███████╗███████╗██████╔╝███████║ +# ╚══════╝╚═╝ ╚══════╝╚══════╝╚═════╝ ╚══════╝ +# +# Long moves are faster than the small ones and used for the bulk of the bowden movement. You can set two fast load speeds +# depending on whether pulling from the spool or filament buffer (if fitted and not the first time load). This can be helpful +# in allowing faster loading from buffer and slower when pulling from the spool because of the additional friction (prevents +# loosing steps). Unloading speed can be tuning if you have a rewinder system that imposes additional limits. +# NOTE: Encoder cannot keep up much above 450mm/s so make sure 'bowden_apply_correction' is off at very high speeds! +# +gear_from_spool_speed: 80 # mm/s Speed when loading from the spool (for the first time if has_filament_buffer: 1) +gear_from_spool_accel: 100 # Acceleration when loading from spool +gear_from_buffer_speed: 150 # mm/s Speed when loading filament from buffer. Conservative is 100mm/s, Max around 400mm/s +gear_from_buffer_accel: 400 # Normal acceleration when loading filament +gear_unload_speed: 80 # mm/s Use (lower) speed when unloading filament (defaults to "from spool" speed) +gear_unload_accel: 100 # Acceleration when unloading filament (defaults to "from spool" accel) +# +gear_short_move_speed: 80 # mm/s Speed when making short moves (like incremental retracts with encoder) +gear_short_move_accel: 600 # Usually the same as gear_from_buffer_accel (for short movements) +gear_short_move_threshold: 70 # Move distance that controls application of 'short_move' speed/accel +gear_homing_speed: 50 # mm/s Speed of gear stepper only homing moves (e.g. homing to gate or extruder) + +# Speeds of extruder movement. The 'sync' speeds will be used when gear and extruder steppers are moving in sync +# +extruder_load_speed: 16 # mm/s speed of load move inside extruder from homing position to meltzone +extruder_unload_speed: 16 # mm/s speed of unload moves inside of extruder (very initial move from meltzone is 50% of this) +extruder_sync_load_speed: 18 # mm/s speed of synchronized extruder load moves +extruder_sync_unload_speed: 18 # mm/s speed of synchronized extruder unload moves +extruder_homing_speed: 18 # mm/s speed of extruder only homing moves (e.g. to toolhead sensor) + +# Selector movement speeds. (Acceleration is defined by physical MMU limits set above and passed to selector stepper driver) +# +selector_move_speed: 200 # mm/s speed of selector movement (not touch) +selector_homing_speed: 60 # mm/s speed of initial selector homing move (not touch) +selector_touch_speed: 80 # mm/s speed of all touch selector moves (if stallguard configured) + +# Selector touch (stallguard) operation. If stallguard is configured, then this can be used to switch on touch movement which +# can detect blocked filament path and try to recover automatically but it is more difficult to set up +# +selector_touch_enabled: 0 # If selector touch operation configured this can be used to disable it 1=enabled, 0=disabled + +# When Happy Hare calls out to a macro for user customization and for parking moves these settings are applied and the previous +# values automatically restored afterwards. This allows for deterministic movement speed regardless of the starting state. +# +macro_toolhead_max_accel: 0 # Default printer toolhead acceleration applied when macros are run. 0 = use printer max +macro_toolhead_min_cruise_ratio: 0.5 # Default printer cruise ratio applied when macros are run + + +# Gate loading/unloading ----------------------------------------------------------------------------------------------- +# ██████╗ █████╗ ████████╗███████╗ ██╗ ██████╗ █████╗ ██████╗ +# ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██║ ███╗███████║ ██║ █████╗ ██║ ██║ ██║███████║██║ ██║ +# ██║ ██║██╔══██║ ██║ ██╔══╝ ██║ ██║ ██║██╔══██║██║ ██║ +# ╚██████╔╝██║ ██║ ██║ ███████╗ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +# These settings control the loading and unloading filament at the gate which is the parking position inside the MMU. +# Typically this would be switch sensor but you can also use an encoder. Even with encoder the endstop can be a switch +# and the encoder used for move verifcation (see advanced 'gate_endstop_to_encoder' option). Note that the `encoder` +# method, due to the nature of its operation will overshoot a little. This is not a problem in practice because the +# overshoot will simply be compensated for in the subsequent move. A +ve parking distance moves towards the MMU, -ve +# moves back through the endstop towards the toolhead. If the MMU has multiple bowden tubes then it is possible to home +# at the extruder sensor and avoid long bowden moves! +# +# Possible gate_homing_endstop names: +# encoder - Detect filament position using movement of the encoder +# mmu_gate - Use gate endstop +# mmu_gear - Use individual per-gate endstop (type-B MMU's) +# extruder - Use extruder entry sensor (Only for some type-B designs, see [mmu_machine] require_bowden_move setting) +# +gate_homing_endstop: encoder # Name of gate endstop, "encoder" forces use of encoder for parking +gate_homing_max: 70 # Maximum move distance to home to the gate (or actual move distance for encoder parking) +gate_preload_homing_max: 70 # Maximum homing distance to the mmu_gear endstop (if MMU is fitted with one) +gate_preload_parking_distance: 0 # Parking position relative to mmu_gear endstop (-ve value means move forward) +gate_unload_buffer: 50 # Amount to reduce the fast unload so that filament doesn't overshoot when parking +gate_load_retries: 2 # Number of times MMU will attempt to grab the filament on initial load (type-A designs) +gate_parking_distance: 23 # Parking position in the gate (distance back from homing point, -ve value means move forward) +gate_endstop_to_encoder: 10 # Distance between gate endstop and encoder (IF both fitted. +ve if encoder after endstop) +gate_autoload: 1 # If pre-gate sensor fitted this controls the automatic loading of the gate +gate_final_eject_distance: 0 # Distance to eject filament on MMU_EJECT (Ignored by MMU_UNLOAD) + + +# Bowden tube loading/unloading ---------------------------------------------------------------------------------------- +# ██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ +# ██╔══██╗██╔═══██╗██║ ██║██╔══██╗██╔════╝████╗ ██║ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██████╔╝██║ ██║██║ █╗ ██║██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║███████║██║ ██║ +# ██╔══██╗██║ ██║██║███╗██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██║██║ ██║ +# ██████╔╝╚██████╔╝╚███╔███╔╝██████╔╝███████╗██║ ╚████║ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +bowden_homing_max: 2000 # Maximum attempted bowden move (for calibration). Should be larger than your actual bowden! + +# If you MMU is equiped with an encoder the following options are available: +# +# In addition to different bowden loading speeds for buffer and non-buffered filament it is possible to detect missed +# steps caused by "jerking" on a heavy spool. If bowden correction is enabled Happy Hare will "believe" the encoder +# reading and make correction moves to bring the filament to within the 'bowden_allowable_load_delta' of the end of +# bowden position (this does require a reliable encoder and is not recommended for very high speed loading >350mm/s) +# +bowden_apply_correction: 0 # 1 to enable, 0 disabled +bowden_allowable_load_delta: 20.0 # How close in mm the correction moves will attempt to get to target +# +# This saftey check uses the encoder to verify the filament is free of extruder before the fast bowden movement to +# reduce possibility of grinding filament. If enabled the trigger can be tuned by setting the "error tolerance" which +# represents the fraction of allowable mismatch between actual movement and that seen by encoder. Setting to 50% tolerance +# usually works well. Increasing will make test more tolerant. Value of 100% essentially disables error detection +# +bowden_pre_unload_test: 1 # 1 to check for bowden movement before full pull (slower), 0 don't check (faster) +bowden_pre_unload_error_tolerance: 50 # ADVANCED: tune pre_unload_test + + +# Extruder homing ----------------------------------------------------------------------------------------------------- +# ███████╗██╗ ██╗████████╗ ██╗ ██╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ +# ██╔════╝╚██╗██╔╝╚══██╔══╝ ██║ ██║██╔═══██╗████╗ ████║██║████╗ ██║██╔════╝ +# █████╗ ╚███╔╝ ██║ ███████║██║ ██║██╔████╔██║██║██╔██╗ ██║██║ ███╗ +# ██╔══╝ ██╔██╗ ██║ ██╔══██║██║ ██║██║╚██╔╝██║██║██║╚██╗██║██║ ██║ +# ███████╗██╔╝ ██╗ ██║██╗ ██║ ██║╚██████╔╝██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ +# ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# Happy Hare needs a reference "homing point" close to the extruder from which to accurately complete the loading of +# the toolhead. This homing operation takes place after the fast bowden load and it is anticipated that that load +# operation will leave the filament just shy of the homing point. If using a toolhead sensor this initial extruder +# homing is unnecessary (but can be forced) because the homing will occur inside the extruder for the optimum in accuracy. +# You still should set this homing method because it is also used for the determination and calibration of bowden length. +# +# In addition to an entry sensor "extruder" it is possible for Happy Hare to "feel" for the extruder gear entry +# by colliding with it. This can be done with encoder based collision detection, the compression of the sync-feedback +# (aka buffer) sensor or using "touch" (stallguard) on the gear stepper. Note that encoder collision detection is not +# completely deterministic and you will have to find the sweetspot for your setup by adjusting the TMC current reduction. +# Note that reduced current during collision detection can also prevent unecessary filament griding. +# +# Possible extruder_homing_endtop names: +# collision - Detect the collision with the extruder gear by monitoring encoder movement (Requires encoder) +# Fast bowden load will move to the extruder gears +# mmu_gear_touch - Use touch detection when the gear stepper hits the extruder (Requires stallguard) +# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home +# extruder - If you have a "filament entry" endstop configured (Requires 'extruder' endstop) +# Fast bowden load will move to extruder_homing_buffer distance before sensor, then home +# filament_compression - If you have a "sync-feedback" sensor with compression switch configured +# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home +# none - Don't attempt to home. Only possibiliy if lacking all sensor options +# Fast bowden load will move to the extruder gears. Option is fine if using toolhead sensor +# Note: The homing_endstop will be ignored ("none") if a toolhead sensor is available unless "extruder_force_homing: 1" +# +extruder_homing_max: 80 # Maximum distance to advance in order to attempt to home the extruder +extruder_homing_endstop: collision # Filament homing method/endstop name (fallback if toolhead sensor not available) +extruder_homing_buffer: 25 # Amount to reduce the fast bowden load so filament doesn't overshoot the extruder homing point +extruder_collision_homing_current: 30 # % gear_stepper current (10%-100%) to use when homing to extruder homing (100 to disable) + +# If you have a toolhead sensor it will always be used as a homing point making the homing outside of the extruder +# potentially unnecessary. However you can still force this initial homing step by setting this option in which case +# the filament will home to the extruder and then home to the toolhead sensor in two steps +# +extruder_force_homing: 0 + + +# Toolhead loading and unloading -------------------------------------------------------------------------------------- +# ████████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗███████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ +# ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██║ ██║ ██║██║ ██║██║ ███████║█████╗ ███████║██║ ██║ ██║ ██║ ██║███████║██║ ██║ +# ██║ ██║ ██║██║ ██║██║ ██╔══██║██╔══╝ ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║ ██║ +# ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║███████╗██║ ██║██████╔╝ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +# It is possible to define highly customized loading and unloading sequences, however, unless you have a specialized +# setup it is probably easier to opt for the built-in toolhead loading and unloading sequence which already offers a +# high degree of customization. If you need even more control then edit the _MMU_LOAD_SEQUENCE and _MMU_UNLOAD_SEQUENCE +# macros in mmu_sequence.cfg - but be careful! +# +# An MMU must have a known point at the end of the bowden from which it can precisely load the extruder. Generally this +# will either be the extruder entrance (which is controlled with settings above) or by homing to toolhead sensor. If +# you have toolhead sensor it is past the extruder gear and the driver needs to know the max distance (from end of +# bowden move) to attempt homing +# +toolhead_homing_max: 40 # Maximum distance to advance in order to attempt to home to defined homing endstop + +# IMPORTANT: These next three settings are based on the physical dimensions of your toolhead +# Once a homing position is determined, Happy Hare needs to know the final move distance to the nozzle. There is only +# one correct value for your setup - use 'toolhead_ooze_reduction' (which corresponds to the residual filament left in +# your nozzle) to control excessive oozing on load. See doc for table of proposed values for common configurations. +# +# NOTE: If you have a toolhead sensor you can automate the calculation of these parameters! Read about the +# `MMU_CALIBRATE_TOOLHEAD` command (https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing#---calibrating-toolhead) +# +toolhead_extruder_to_nozzle: 72 # Distance from extruder gears (entrance) to nozzle +toolhead_sensor_to_nozzle: 62 # Distance from toolhead sensor to nozzle (ignored if not fitted) +toolhead_entry_to_extruder: 8 # Distance from extruder "entry" sensor to extruder gears (ignored if not fitted) + +# This setting represents how much residual filament is left behind in the nozzle when filament is removed, it is thus +# used to reduce the extruder loading length and prevent excessive blobbing but also in the calculation of purge volume. +# Note that this value can also be measured with the `MMU_CALIBRATE_TOOLHEAD` procedure +# +toolhead_residual_filament: 0 # Reduction in extruder loading length because of residual filament left behind + +# TUNING: Finally, this is the last resort tuning value to fix blobbing. It is expected that this value is NEAR ZERO as +# it represents a further reduction in extruder load length to fix blobbing. If using a wipetower and you experience blobs +# on it, increase this value (reduce the quantity of filament loaded). If you experience gaps, decrease this value. If gaps +# and already at 0 then perhaps the 'toolhead_extruder_to_nozzle' or 'toolhead_residual_filament' settings are incorrect. +# Similarly a value >+5mm also suggests the four settings above are not correct. Also see 'retract' setting in +# 'mmu_macro_vars.cfg' for final in-print ooze tuning. +# +toolhead_ooze_reduction: 0 # Reduction in extruder loading length to prevent ooze (represents filament remaining) + +# Distance added to the extruder unload movement to ensure filament is free of extruder. This adds some degree of tolerance +# to slightly incorrect configuration or extruder slippage. However don't use as an excuse for incorrect toolhead settings +# +toolhead_unload_safety_margin: 10 # Extra movement safety margin (default: 10mm) + +# If not synchronizing gear and extruder and you experience a "false" clog detection immediately after the tool change +# it might be because of a long bowden and/or large internal diameter that causes slack in the filament. This optional +# move will tighten the filament after a load by % of current clog detection length. Gear stepper will run at 50% current +# +toolhead_post_load_tighten: 60 # % of clog detection length, 0 to disable. Ignored if 'sync_to_extruder: 1' + +# If synchronizing gear and extruder and you have a sync-feedback "buffer" this setting determines whether to use it +# to create neutral tension after loading +toolhead_post_load_tension_adjust: 1 # 1 to enable (recommended), 0 to disable + +# If sync-feedback compression sensor is available this test will ensure the filament passes the extruder entry by checking +# for neutral tension when moving filament with just the extruder. Recommended with sprung loaded sync-feedback buffers. +# This is ignored if toolhead sensor is available. +toolhead_entry_tension_test: 1 # 1 to enable (recommended), 0 to disable + +# ADVANCED: Controls the detection of successful extruder load/unload movement and represents the fraction of allowable +# mismatch between actual movement and that seen by encoder. Setting to 100% tolerance effectively turns off checking. +# Some designs of extruder have a short move distance that may not be picked up by encoder and cause false errors. This +# allows masking of those errors. However the error often indicates that your extruder load speed is too high or the +# friction is too high on the filament and in that case masking the error is not a good idea. Try reducing friction +# and lowering speed first! +# +toolhead_move_error_tolerance: 60 + + +# Tip forming --------------------------------------------------------------------------------------------------------- +# ████████╗██╗██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ +# ╚══██╔══╝██║██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ +# ██║ ██║██████╔╝ █████╗ ██║ ██║██████╔╝██╔████╔██║██║██╔██╗ ██║██║ ███╗ +# ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██║ ██║ +# ██║ ██║██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# Tip forming responsibility can be split between slicer (in-print) and standalone macro (not in-print) or forced to always +# be done by Happy Hare's standalone macro. Since you always need the option to form tips without the slicer so it is +# generally easier to completely turn off the slicer, force "standalone" tip forming and tune only in Happy Hare. +# +# When Happy Hare is asked to form a tip it will run the referenced macro. Two are reference examples are provided but +# you can implement your own: +# _MMU_FORM_TIP .. default tip forming similar to popular slicers like Superslicer and Prusaslicer +# _MMU_CUT_TIP .. for Filametrix (originally ERCFv2) or similar style toolhead filament cutting system +# +# NOTE: For MMU located cutting like the optional EREC cutter you should set still this to _MMU_FORM_TIP to build a decent +# tip prior to extraction and cutting after the unload. +# +# Often it is useful to increase the extruder current for the rapid movement to ensure high torque and no skipped steps +# +# If opting for slicer tip forming you MUST configure where the slicer leaves the filament in the extruder since +# there is no way to determine this. This can be ignored if all tip forming is performed by Happy Hare +# +force_form_tip_standalone: 1 # 0 = Slicer in print else standalone, 1 = Always standalone tip forming (TURN SLICER OFF!) +form_tip_macro: _MMU_FORM_TIP # Name of macro to call to perform the tip forming (or cutting) operation +extruder_form_tip_current: 100 # % of extruder current (100%-150%) to use when forming tip (100 to disable) +slicer_tip_park_pos: 0 # This specifies the position of filament in extruder after slicer completes tip forming + + +# Purging ------------------------------------------------------------------------------------------------------------- +# ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ +# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██║████╗ ██║██╔════╝ +# ██████╔╝██║ ██║██████╔╝██║ ███╗██║██╔██╗ ██║██║ ███╗ +# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║ +# ██║ ╚██████╔╝██║ ██║╚██████╔╝██║██║ ╚████║╚██████╔╝ +# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# After a toolchange it is necessary to purge the old filament. Similar to tip forming this can be done by the slicer and/or +# by Happy Hare using an extension like Blobifer. If a purge_macro is defined it will be called when not printing or whenever +# the slicer isn't going to purge (like initial tool load). You can force it to always be called in a print by setting +# force_purge_standalone, but remember to turn off the slicer wipetower +# +# The default is for no (empty) macro so purging will not be done out of a print and thus wipetower. Two options are shipped with +# Happy Hare but you can also build your own custom one: +# _MMU_PURGE .. default purging that just dumps the desired amount of filament (setup correct parking before enabling this!) +# BLOBIFER .. for excellent Blobifer addon (https://github.com/Dendrowen/Blobifier) +# +# Often it is useful to increase the extruder current for the often rapid puring movement to ensure high torque and no skipped steps +# +force_purge_standalone: 0 # 0 = Slicer wipetower in print else standalone, 1 = Always standalone purging (TURN WIPETOWER OFF!) +purge_macro: _MMU_PURGE # Name of macro to call to perform the standalone purging operation. E.g. BLOBIFIER, _MMU_PURGE +extruder_purge_current: 100 # % of extruder current (100%-150%) to use when purging (100 to disable) + + +# Synchronized gear/extruder movement ---------------------------------------------------------------------------------- +# ███╗ ███╗ ██████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗███╗ ██╗ ██████╗ +# ████╗ ████║██╔═══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██╔════╝╚██╗ ██╔╝████╗ ██║██╔════╝ +# ██╔████╔██║██║ ██║ ██║ ██║ ██║██████╔╝ ███████╗ ╚████╔╝ ██╔██╗ ██║██║ +# ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║██╔══██╗ ╚════██║ ╚██╔╝ ██║╚██╗██║██║ +# ██║ ╚═╝ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║ ███████║ ██║ ██║ ╚████║╚██████╗ +# ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ +# +# This controls whether the extruder and gear steppers are synchronized during printing operations +# If you normally run with maxed out gear stepper current consider reducing it with 'sync_gear_current' +# If equipped with TMC drivers the current of the gear and extruder motors can be controlled to optimize performance. +# This can be useful to control gear stepper temperature when printing with synchronized motor +# +sync_to_extruder: 0 # Gear motor is synchronized to extruder during print +sync_gear_current: 70 # % of gear_stepper current (10%-100%) to use when syncing with extruder during print +sync_form_tip: 0 # Synchronize during standalone tip formation (initial part of unload) +sync_purge: 0 # Synchronize during standalone purging (last part of load) + +# Optionally it is possible to leverage feedback from a "compression/expansion" sensor (aka "buffer") in the bowden +# path from MMU to extruder to ensure that the two motors are kept in sync as viewed by the filament (the signal feedback +# state can be binary supplied by one or two switches: -1 (expanded) and 1 (compressed) of proportional value between +# -1.0 and 1.0. +# +# If only "one half" of the sync-feedback is available (either compression-only or tension-only) then the rotation +# distance is always shifted based on the high/low multipliers, however if both tension and compression are available +# then the rotation distance will autotune to correct setting (recommend you also enable 'autotune_rotation_distance: 1' +# Note that proportional feedback sensors are continuously dynamic +# +# Possible buffer setups, forth option for type where neutral is when both sensors are active: +# +# <------maxrange------> <------maxrange------> <------maxrange------> <------maxrange------> +# <--range---> <----range-----> <----range-----> <> range=0 +# |====================| |====================| |====================| |====================| +# ^ ^ ^ ^ ^^ +# compression tension compression-only tension-only +# +sync_feedback_enabled: 0 # Turn off even if sensor is installed and active +sync_feedback_buffer_range: 6 # Travel in "buffer" between compression/tension or one sensor and end (see above) +sync_feedback_buffer_maxrange: 12 # Absolute maximum end-to-end travel (mm) provided by buffer (see above) +sync_feedback_speed_multiplier: 5 # % "twolevel" gear speed delta to keep filament neutral in buffer (recommend 5%) +sync_feedback_boost_multiplier: 3 # % "twolevel" extra gear speed boost for finding initial neutral position (recommend 3%) +sync_feedback_extrude_threshold: 5 # Extruder movement (mm) for updates (keep small but set > retract distance) + +# If defined this forces debugging to a telemetry log file "sync_.jsonl". This is great if trying to tune clog/tangle +# detection or for getting help on the Happy Hare forum. To plot graph of sync-feedback operation, run: +# ~/Happy-Hare/utils/plot_sync_feedback.sh +# +sync_feedback_debug_log: 0 # 0 = disable (normal opertion), 1 = enable telemetry log (for debugging) + + +# ESpooler control ----------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ +# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ +# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ +# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ +# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ +# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ +# +# If your MMU has a dc motor (often N20) controlled respooler/assist then how it operates can be controlled with these +# settings. Typically the espooler will be controlled with PWM signal. This will be at the maximum at speeds equal or +# above 'espooler.max_stepper_speed'. The PWM signal will scale downwards towards 0 for slower speeds. The falloff being +# controlled by the 'espooler_speed_exponent' setting according to this formula and allows for non-linear characteristics +# the DC motor (0.5 is a good starting value). +# +# espooler_pwm = (stepper_speed / espooler_max_stepper_speed) ^ {espooler_speed_exponent} +# +# Regardless of h/w configuration you can enable/disable actions with the 'espooler_operations' list. E.g. remove 'play' to +# turn off operation while printing. Options are: +# +# rewind - when filament is being unloaded under MMU control (aka respool) +# assist - when filament is being loaded under MMU control (% of "rewind" speed but with minimum of "print" power) +# print - while printing. Generally set 'espooler_printing_power' to a low percentage just to allow motor to be turned +# freely or set to 0 to enable/allow "burst" assist movements +# +# If using a digitally controlled espooler motor (not PWM) then you should turn off the "print" mode and set +# 'espooler_min_stepper_speed' to prevent "over movement" +# +espooler_min_distance: 30 # Individual stepper movements less than this distance will not active espooler +espooler_max_stepper_speed: 300 # Gear stepper speed at which espooler will be at maximum power +espooler_min_stepper_speed: 0 # Gear stepper speed at which espooler will become inactive (useful for non PWM control) +espooler_speed_exponent: 0.5 # Controls non-linear espooler power relative to stepper speed (see notes) +espooler_assist_reduced_speed: 50 # Control the % of the rewind speed that is applied to assisting load (want rewind to be faster) +espooler_printing_power: 0 # If >0, fixes the % of PWM power while printing. 0=allows burst movement +espooler_operations: rewind, assist, print # List of operational modes (allows disabling even if h/w is configured) +# +# The following burst configuration is used to control the small rotation in the ASSIST direction optionally used +# when in 'print' operation is enabled, 'espooler_printing_power: 0' and is triggered (tension switch or extruder movement). +# It can also be used to loosen filament with 'MMU_ESPOOLER COMMAND=assist BURST=1' +# +espooler_assist_extruder_move_length: 100 # Distance (mm) extruder needs to move between each assist burst +espooler_assist_burst_power: 100 # The % power of the burst move +espooler_assist_burst_duration: 0.4 # The duration of the burst move is seconds +espooler_assist_burst_trigger: 0 # If trigger assist switch is fitted 0=disable, 1=enable +espooler_assist_burst_trigger_max: 3 # If trigger assist switch is fitted this limits the max number of back-to-back advances +# +# The following burst configuration is used to control the small rotation in the REWIND direction optionally used +# when running running the filament drying cycle. The goal is to rotate the spool 60-90 degrees. It can also be +# used to tighten the filament with 'MMU_ESPOOLER COMMAND=rewind BURST=1' +# +espooler_rewind_burst_power: 100 # The % power of the rewind burst move +espooler_rewind_burst_duration: 0.4 # The duration of the rewind burst move is seconds + + +# Heater / Environment Management ------------------------------------------------------------------------------------ +# ██╗ ██╗███████╗ █████╗ ████████╗███████╗██████╗ +# ██║ ██║██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ +# ███████║█████╗ ███████║ ██║ █████╗ ██████╔╝ +# ██╔══██║██╔══╝ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ +# ██║ ██║███████╗██║ ██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +heater_max_temp: 70 # Absolute max heater setting to protect the MMU enclosure construction (adjust to match material) +heater_default_dry_temp: 45 # Default drying temperature if filament type is not matched in drying_data +heater_default_dry_time: 300 # Default drying cycle time in minutes +heater_default_humidity: 25 # Default humidity % goal. Drying will terminate if this value is reached +heater_vent_macro: _MMU_VENT # Name of macro to periodicaly call during drying cycle +heater_vent_interval: 0 # Interval in minutes to call heater_vent_macro during drying cycle, 0=disable venting +heater_rotate_interval: 5 # Interval in minutes to rotate filament (requires eSpooler and filament end attached to spool) + +# Drying data for MMU_HEATER DRY=1 command in form (material type is case insensitive): +# 'filament_type': (temp, drying_time_mins) +# +# (Careful with formatting of this line - reformatting will break upgrade logic) +# +drying_data: { 'pla': (45, 300), 'pla+': (55, 300), 'petg': (60, 300), 'tpu': (55, 300), 'abs': (70, 300), 'abs+': (75, 300), 'asa': (65, 300), 'nylon': (75, 600), 'pc': (75, 600), 'pva': (75, 600), 'hips': (75, 600) } + + +# FlowGuard Clog and Tangle Detection -------------------------------------------------------------------------------- +# ███████╗██╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ +# ██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗ +# █████╗ ██║ ██║ ██║██║ █╗ ██║██║ ███╗██║ ██║███████║██████╔╝██║ ██║ +# ██╔══╝ ██║ ██║ ██║██║███╗██║██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║ +# ██║ ███████╗╚██████╔╝╚███╔███╔╝╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝ +# ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ +# +# Options are available to automatically detects extruder clogs and MMU tangles. Each option works independently and +# can be combined. Flowguard can even discern the difference between an extruder clog and a spool tangle! +# +# Flowguard: This intelligently measures filament tension (only available if sync-feedback buffer is fitted) +# +# Encoder detection: This monitors encoder movement and compares to extruder (only available if encoder is fitted) +# +flowguard_enabled: 1 # 0 = Flowguard protection disabled, 1 = Enabled + +# The flowguard_max_relief is the amount of relief movement (effective mm change in filament length between MMU and extruder) +# that Happy Hare will wait until triggering a clog or runout. A smaller value is more sensitive to triggering. Since the +# relief movement is hightly dependent on filament "spring" in the bowden tube, filament friction, and +# 'sync_feedback_buffer_range', it is generally good to start high and then decrease if a more sensitive trigger is desired. +# Analog proportional (type P) sensors can generally have a much lower value. Increase if you have false triggers. +flowguard_max_relief: 40 + +# Encoder runout/clog/tangle detection watches for movement over either a static or automatically adjusted distance - if +# no encoder movement is seen when the extruder moves this distance runout/ clog/tangle event will be generated. Allowing +# the distance to be adjusted automatically (mode=2) will generally allow for a quicker trigger but use a static length +# (mode=1, set encoder_max_motion) if you get false triggers (see flowguard guide on wiki for more details) +# Note that this feature cannot disinguish between clog or tangle. +flowguard_encoder_mode: 2 # 0 = Disable, 1 = Static length clog detection, 2 = Automatic length clog detection + +# The encoder_max_motion is the absolute max permitted extruder movement without the encoder seeing movement when using +# status mode (mode=1). Smaller values are more sensitive but beware of going too small - slack and friction in the +# bowden may cause gaps in encoder movement. Increase if you have false triggers +# Note that this value is overriden by any calibrated value stored in 'mmu_vars.cfg' if in automatic mode (mode=2). +flowguard_encoder_max_motion: 20 + + +# Filament Management Options ---------------------------------------------------------------------------------------- +# ███████╗██╗██╗ ███╗ ███╗ ██████╗ ███╗ ███╗████████╗ +# ██╔════╝██║██║ ████╗ ████║██╔════╝ ████╗ ████║╚══██╔══╝ +# █████╗ ██║██║ ██╔████╔██║██║ ███╗██╔████╔██║ ██║ +# ██╔══╝ ██║██║ ██║╚██╔╝██║██║ ██║██║╚██╔╝██║ ██║ +# ██║ ██║███████╗██╗ ██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ +# +# - EndlessSpool feature allows detection of runout on one spool and the automatic mapping of tool to an alternative +# gate (spool). Set to '1', this feature requires clog detection or gate sensor or pre-gate sensors. EndlessSpool +# functionality can optionally be extended to attempt to load an empty gate with 'endless_spool_on_load'. On some MMU +# designs (with linear selector) it can also be configured to eject filament remains to a designated gate rather than +# defaulting to current gate. A custom gate will disable pre-gate runout detection for EndlessSpool because filament +# end must completely pass through the gate for selector to move +# +endless_spool_enabled: 1 # 0 = disable, 1 = enable endless spool +endless_spool_on_load: 0 # 0 = don't apply endless spool on load, 1 = run endless spool if gate is empty +endless_spool_eject_gate: -1 # Which gate to eject the filament remains. -1 = current gate +#endless_spool_groups: # Default EndlessSpool groups (see later in file) +# +# Spoolman support requires you to correctly enable spoolman with moonraker first. If enabled, the gate SpoolId will +# be used to load filament details and color from the spoolman database and Happy Hare will activate/deactivate +# spools as they are used. The enabled variation allows for either the local map or the spoolman map to be the +# source of truth as well as just fetching filament attributes. See this table for explanation: +# +# | Activate/ | Fetch filament attributes | Filament gate | Filament gate | +# spoolman_support | Deactivate | attributes from spoolman | assignment shown | assignment pulled | +# | spool? | based on spool_id? | in spoolman db? | from spoolman db? | +# -----------------+------------+---------------------------+------------------+-------------------+ +# off | no | no | no | no | +# readonly | yes | yes | no | no | +# push | yes | yes | yes | no | +# pull | yes | yes | yes | yes | +# +spoolman_support: off # off = disabled, readonly = enabled, push = local gate map, pull = remote gate map +pending_spool_id_timeout: 20 # Seconds after which this pending spool_id (set with rfid) is voided +# +# Mainsail/Fluid UI can visualize the color of filaments next to the extruder/tool chooser. The color is dynamic and +# can be customized to your choice: +# +# slicer - Color from slicer tool map (what the slicer expects) +# allgates - Color from all the tools in the gate map after running through the TTG map +# gatemap - As per gatemap but hide empty tools +# off - Turns off support +# +# Note: Happy Hare will also add the 'spool_id' variable to the Tx macro if spoolman is enabled +# +t_macro_color: slicer # 'slicer' = default | 'allgates' = mmu | 'gatemap' = mmu without empty gates | 'off' + + +# Print Statistics --------------------------------------------------------------------------------------------------- +# ███████╗████████╗ █████╗ ████████╗███████╗ +# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ +# ███████╗ ██║ ███████║ ██║ ███████╗ +# ╚════██║ ██║ ██╔══██║ ██║ ╚════██║ +# ███████║ ██║ ██║ ██║ ██║ ███████║ +# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ +# +# These parameters determine how print statistic data is shown in the console. This table can show a lot of data, +# probably more than you'd want to see. Below you can enable/disable options to your needs. +# +# +-----------+---------------------+----------------------+----------+ +# | 114(46) | unloading | loading | complete | +# | swaps | pre | - | post | pre | - | post | swap | +# +-----------+------+-------+------+------+-------+-------+----------+ +# | all time | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | +# | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | +# | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | +# | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | +# | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | +# +-----------+------+-------+------+------+-------+-------+----------+ +# Note: Only formats correctly on Python3 +# +# Comma separated list of desired columns +# Options: pre_unload, form_tip, unload, post_unload, pre_load, load, purge, post_load, total +console_stat_columns: unload, load, post_load, total + +# Comma separated list of rows. The order determines the order in which they're shown. +# Options: total, total_average, job, job_average, last +console_stat_rows: total, total_average, job, job_average, last + +# How you'd want to see the state of the gates and how they're performing +# string - poor, good, perfect, etc.. +# percentage - rate of success +# emoticon - fun sad to happy faces (python3 only) +console_gate_stat: emoticon + +# Always display the full statistics table +console_always_output_full: 1 # 1 = Show full table, 0 = Only show totals out of print + + +# Calibration and autotune ------------------------------------------------------------------------------------------- +# ██████╗ █████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ +# ██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ +# ██║ ███████║██║ ██║██████╔╝██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ +# ██║ ██╔══██║██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ +# ╚██████╗██║ ██║███████╗██║██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ +# ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ +# +# These are auto calibration/tuning settings that can be used to ease initial setup and/or to tune calibration over +# time based on measured telemetry. Whether these auto-tuning features are available depends on MMU design and +# configured sensors (explained below). The setting will be ignored if the required sensors are not available but if +# they can operate they will suppress the normal calibration warnings (MMU_STATUS can still be used to view them). +# Note that these are initially set by the installer to recommended values +# +# autocal_bowden_length - the calibrated bowden length will be established on first load. It can also be set +# manually or reset with MMU_CALIBRATE_BOWDEN. Best results require the use of +# sync-feedback-compression or extruder sensor but gear-touch or encoder will also work. +# 'extruder_homing_endstop' cannot be 'none' +# autotune_bowden_length - Once calibrated this setting will tune the bowden distance over time. Works best with +# toolhead sensor +# skip_cal_rotation_distance - This will rely on installed default value (although it can still be calibrated). Usually +# a good choice if autotune is enabled +# autotune_rotation_distance - Requires sync-feedback sensor (aka "buffer") or calibrated encoder. If set then either the +# "autotuner" (sync-feedback buffer) or encoder telemetry will be used to adjust the +# persisted gear rotation distance. +# skip_cal_encoder - Will rely on installed default value (although it can still be calibrates). +# Not recommended but allows for easier initial setup especially when 'autotune_encoder' +# is enabled. +# autotune_encoder - NOT IMPLEMENTED YET. Soon! +# +autocal_bowden_length: 1 # Automated bowden length calibration. 1=automatic, 0=manual/off +autotune_bowden_length: 1 # Automated bowden length tuning. 1=on, 0=off +skip_cal_rotation_distance: 0 # Skip rotation distance calibration (MMU_CALIBRATE_GEAR), 1=skip, 0=require +autotune_rotation_distance: 0 # Automated gate calibration/tuning. 1=automatic, 0=manual/off +skip_cal_encoder: 0 # Skip encoder calibration (MMU_CALIBRATE_ENCODER), 1=skip, 0=require +autotune_encoder: 0 # Automated encoder tuning. 1=automatic, 0=manual/off + + +# Miscellaneous, but you should review ------------------------------------------------------------------------------- +# ███╗ ███╗██╗███████╗ ██████╗ +# ████╗ ████║██║██╔════╝██╔════╝ +# ██╔████╔██║██║███████╗██║ +# ██║╚██╔╝██║██║╚════██║██║ +# ██║ ╚═╝ ██║██║███████║╚██████╗ +# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ +# +# Important you verify these work for you setup/workflow. Temperature and timeouts +# +timeout_pause: 72000 # Idle time out (printer shuts down) in seconds used when in MMU pause state +disable_heater: 600 # Delay in seconds after which the hotend heater is disabled in the MMU_PAUSE state +default_extruder_temp: 200 # Default temperature for performing swaps and forming tips when not in print (overridden by gate map) +extruder_temp_variance: 2 # When waiting for extruder temperature this is the +/- permissible variance in degrees (>= 1) +# +# Other workflow options +# +startup_home_if_unloaded: 0 # 1 = force mmu homing on startup if unloaded, 0 = do nothing +startup_reset_ttg_map: 0 # 1 = reset TTG map on startup, 0 = do nothing +show_error_dialog: 1 # 1 = show pop-up dialog in addition to console message, 0 = show error in console +preload_attempts: 5 # How many "grabbing" attempts are made to pick up the filament with preload feature +strict_filament_recovery: 0 # If enabled with MMU with toolhead sensor, this will cause filament position recovery to + # perform extra moves to look for filament trapped in the space after extruder but before sensor +filament_recovery_on_pause: 1 # 1 = Run a quick check to determine current filament position on pause/error, 0 = disable +retry_tool_change_on_error: 0 # Whether to automatically retry a failed tool change. If enabled Happy Hare will perform + # the equivalent of 'MMU_RECOVER' + 'Tx' commands which usually is all that is necessary + # to recover. Note that enabling this can mask problems with your MMU +bypass_autoload: 1 # If extruder sensor fitted this controls the automatic loading of extruder for bypass operation +has_filament_buffer: 1 # Whether the MMU has a filament buffer. Set to 0 if using Filamentalist or DC eSpooler, etc +# +# Advanced options. Don't mess unless you fully understand. Read documentation. +# +encoder_move_validation: 1 # ADVANCED: 1 = Normally Encoder validates move distances are within given tolerance + # 0 = Validation is disabled (eliminates slight pause between moves but less safe) +print_start_detection: 1 # ADVANCED: Enabled for Happy Hare to automatically detect start and end of print and call + # ADVANCED: MMU_PRINT_START and MMU_PRINT_END automatically. Harmless to leave enabled but can disable + # if you think it is causing problems and known START/END is covered in your macros +extruder: extruder # ADVANCED: Name of the toolhead extruder that MMU is using +gcode_load_sequence: 0 # VERY ADVANCED: Gcode loading sequence 1=enabled, 0=internal logic (default) +gcode_unload_sequence: 0 # VERY ADVANCED: Gcode unloading sequence, 1=enabled, 0=internal logic (default) + + +# ADVANCED: Klipper tuning ------------------------------------------------------------------------------------------- +# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ +# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ +# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ +# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ +# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +# Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing +# operations especially so with CANbus connected MCUs. Happy Hare uses many homing moves for reliable extruder loading +# and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error +# +update_trsync: 0 # 1 = Increase TRSYNC_TIMEOUT, 0 = Leave the klipper default +# +# Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms timeout will kill +# the print. Since it seems to occur only on homing moves they can be safely retried to workaround. This has been +# working well in practice +canbus_comms_retries: 3 # Number of retries. Recommend the default of 3. +# +# Older neopixels have very finicky timing and can generate lots of "Unable to obtain 'neopixel_result' response" +# errors in klippy.log. An often cited workaround is to increase BIT_MAX_TIME in neopixel.py. This option does that +# automatically for you to save dirtying klipper +update_bit_max_time: 1 # 1 = Increase BIT_MAX_TIME, 0 = Leave the klipper default +# +# BTT ViViD used a AHT30 sensor. If you are using an older klipper you may not have this sensor available. If so, use +# AHT10 and set this to 1 to convert AHT30 commands to AHT10 +update_aht10_commands: 0 # 1 = Config AHT10 for BTT ViViD heater sensor on older klipper, 0 = Leave the klipper default + + +# ADVANCED: MMU macro overrides --- ONLY SET IF YOU'RE COMFORTABLE WITH KLIPPER MACROS ------------------------------- +# ███╗ ███╗ █████╗ ██████╗██████╗ ██████╗ ███████╗ +# ████╗ ████║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔════╝ +# ██╔████╔██║███████║██║ ██████╔╝██║ ██║███████╗ +# ██║╚██╔╝██║██╔══██║██║ ██╔══██╗██║ ██║╚════██║ +# ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║╚██████╔╝███████║ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ +# +# 'pause_macro' defines what macro to call on MMU error (must put printer in paused state) +# Other macros are detailed in 'mmu_sequence.cfg' +# Also see form_tip_macro in Tip Forming section and purge_macro in Purging section +# +pause_macro: PAUSE # What macro to call to pause the print +action_changed_macro: _MMU_ACTION_CHANGED # Called when action (printer.mmu.action) changes +print_state_changed_macro: _MMU_PRINT_STATE_CHANGED # Called when print state (printer.mmu.print_state) changes +mmu_event_macro: _MMU_EVENT # Called on useful MMU events +pre_unload_macro: _MMU_PRE_UNLOAD # Called before starting the unload +post_form_tip_macro: _MMU_POST_FORM_TIP # Called immediately after tip forming +post_unload_macro: _MMU_POST_UNLOAD # Called after unload completes +pre_load_macro: _MMU_PRE_LOAD # Called before starting the load +post_load_macro: _MMU_POST_LOAD # Called after the load is complete +unload_sequence_macro: _MMU_UNLOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_unload_sequence' +load_sequence_macro: _MMU_LOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_load_sequence' + + +# ADVANCED: See documentation for use of these ----------------------------------------------------------------------- +# ██████╗ ███████╗███████╗███████╗████████╗ ██████╗ ███████╗███████╗███████╗ +# ██╔══██╗██╔════╝██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██╔════╝ +# ██████╔╝█████╗ ███████╗█████╗ ██║ ██║ ██║█████╗ █████╗ ███████╗ +# ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║ ██║ ██║██╔══╝ ██╔══╝ ╚════██║ +# ██║ ██║███████╗███████║███████╗ ██║ ██████╔╝███████╗██║ ███████║ +# ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ +# +# These are the values that the various "RESET" commands will reset too rather than the built-in defaults. The lenght +# of the lists must match the number of gates on your MMU +# +# e.g. MMU_GATE_MAP RESET=1 - will use all the 'gate_XXX' values +# MMU_TTG_MAP RESET=1 - will use the 'tool_to_gate_map' +# MMU_ENDLESS_SPOOL_GROUPS RESET=1 - will use the 'endless_spool_groups' +# +# Gate: #0 #1 #2 #3 #4 #5 #6 #7 #8 +#gate_status: 1, 0, 1, 2, 2, -1, -1, 0, 1 +#gate_filament_name: one, two, three, four, five, six, seven, eight, nine +#gate_material: PLA, ABS, ABS, ABS+, PLA, PLA, PETG, TPU, ABS +#gate_color: red, black, yellow, green, blue, indigo, ffffff, grey, black +#gate_temperature: 210, 240, 235, 245, 210, 200, 215, 240, 240 +#gate_spool_id: 3, 2, 1, 4, 5, 6, 7, -1, 9 +#gate_speed_override: 100, 100, 100, 100, 100, 100, 100, 50, 100 +#endless_spool_groups: 0, 1, 2, 1, 0, 0, 3, 4, 1 +# +# Tool: T0 T1 T2 T3 T4 T5 T6 T7 T8 +#tool_to_gate_map: 0, 1, 2, 3, 4, 5, 6, 7, 8 + + +# ADVANCED/CUSTOM MMU: See documentation for use of these ------------------------------------------------------------ +# ██████╗██╗ ██╗███████╗████████╗ ██████╗ ███╗ ███╗ ███╗ ███╗███╗ ███╗██╗ ██╗ +# ██╔════╝██║ ██║██╔════╝╚══██╔══╝██╔═══██╗████╗ ████║ ████╗ ████║████╗ ████║██║ ██║ +# ██║ ██║ ██║███████╗ ██║ ██║ ██║██╔████╔██║ ██╔████╔██║██╔████╔██║██║ ██║ +# ██║ ██║ ██║╚════██║ ██║ ██║ ██║██║╚██╔╝██║ ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ +# ╚██████╗╚██████╔╝███████║ ██║ ╚██████╔╝██║ ╚═╝ ██║ ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ +# ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ +# +# Normally all these settings are set based on your choice of 'mmu_vendor' and 'mmu_version' in mmu_hardware.cfg, but they +# can be overridden. If you have selected a vendor of "Other" and your MMU has a selector you must set these CAD based +# dimensions else you will get arbitrary defaults. You may also need to set additional attributes in '[mmu_machine]' +# section of mmu_hardware.cfg. +# +#cad_gate0_pos: 4.2 # Approximate distance from endstop to first gate. Used for rough calibration only +#cad_gate_width: 21.0 # Width of each gate +#cad_bypass_offset: 0 # Distance from limit of travel back to the bypass (e.g. ERCF v2.0) +#cad_last_gate_offset: 2.0 # Distance from limit of travel back to last gate +#cad_selector_tolerance: 10.0 # How much extra selector movement to allow for calibration +#cad_gate_directions = [1, 1, 0, 0] # Directions of gear depending on gate (3DChameleon) +#cad_release_gates = [2, 3, 0, 1] # Gate to move to when releasing filament (3DChameleon) diff --git a/config/base/mmu_parameters.cfg.ss b/config/base/mmu_parameters.cfg.ss new file mode 100644 index 000000000000..c728f9d4fd0b --- /dev/null +++ b/config/base/mmu_parameters.cfg.ss @@ -0,0 +1,783 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# Template file for MMU's with Servo Selector (Type-A designs like PicoMMU and MMX) +# This file omits selector and selector-servo parts of the configuration and a few other options that don't make sense +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Main configuration parameters for the klipper module +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# Notes: +# Macro configuration is specified separately in 'mmu_macro_vars.cfg'. +# Full details in https://github.com/moggieuk/Happy-Hare/tree/main/doc/configuration.md +# +[mmu] +happy_hare_version: {happy_hare_version} # Don't mess, used for upgrade detection + +# MMU Hardware Limits -------------------------------------------------------------------------------------------------- +# ██╗ ██╗███╗ ███╗██╗████████╗███████╗ +# ██║ ██║████╗ ████║██║╚══██╔══╝██╔════╝ +# ██║ ██║██╔████╔██║██║ ██║ ███████╗ +# ██║ ██║██║╚██╔╝██║██║ ██║ ╚════██║ +# ███████╗██║██║ ╚═╝ ██║██║ ██║ ███████║ +# ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ +# +# Define the physical limits of your MMU. These settings will be respected regardless of individual speed settings. +# +gear_max_velocity: 300 # Never to be exceeded gear velocity regardless of specific parameters +gear_max_accel: 1500 # Never to be exceeded gear acceleration regardless of specific parameters + + +# Selector servo configuration ---------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ +# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗ +# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║ +# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║ +# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝ +# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ +# +# Selector servo positions are stored in `mmu_vars.cfg` after calibration. +# Note that the "release angle" is by default the nearest position between calibrated selection angles. This can be overriden +# by setting and explicit servo_release_angle +# +# Note that leaving the servo active when down can stress the electronics and is not recommended unless you have a good +# 5v power supply. Make sure your hardware is suitable for the job! +# +servo_duration: 0.5 # Duration of PWM burst sent to servo (default non-active mode, automatically turns off) +servo_dwell: 0.8 # Minimum time given to servo to complete movement prior to next move +servo_always_active: 0 # CAUTION: 1=Force servo to always stay active, 0=Release after movement +selector_gate_angles: 45, 90, 135, 180 # Optionally set default list of gate angles (overriden by calibration) +selector_bypass_angle: -1 # Optionally set default servo angle when bypass is selected, -1=No default +selector_release_angle: -1 # Optionally force a specific "release" angle, -1=Default (between gate angles) behavior + + +# Logging -------------------------------------------------------------------------------------------------------------- +# ██╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ +# ██║ ██╔═══██╗██╔════╝ ██╔════╝ ██║████╗ ██║██╔════╝ +# ██║ ██║ ██║██║ ███╗██║ ███╗██║██╔██╗ ██║██║ ███╗ +# ██║ ██║ ██║██║ ██║██║ ██║██║██║╚██╗██║██║ ██║ +# ███████╗╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝ +# ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# log_level & logfile_level can be set to one of (0 = essential, 1 = info, 2 = debug, 3 = trace, 4 = stepper moves) +# Generally you can keep console logging to a minimal whilst still sending debug output to the mmu.log file +# Increasing the console log level is only really useful during initial setup to save having to constantly open the log file +# Note: that it is not recommended to keep logging at level greater that 2 (debug) if not debugging an issue because +# of the additional overhead +# +log_level: 1 +log_file_level: 2 # Can also be set to -1 to disable log file completely +log_statistics: 1 # 1 to log statistics on every toolchange (default), 0 to disable (but still recorded) +log_visual: 1 # 1 log visual representation of filament, 0 = disable +log_startup_status: 1 # Whether to log tool to gate status on startup, 1 = summary (default), 0 = disable +log_m117_messages: 1 # Whether send toolchange message via M117 to screen + + +# Movement speeds ------------------------------------------------------------------------------------------------------ +# ███████╗██████╗ ███████╗███████╗██████╗ ███████╗ +# ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝ +# ███████╗██████╔╝█████╗ █████╗ ██║ ██║███████╗ +# ╚════██║██╔═══╝ ██╔══╝ ██╔══╝ ██║ ██║╚════██║ +# ███████║██║ ███████╗███████╗██████╔╝███████║ +# ╚══════╝╚═╝ ╚══════╝╚══════╝╚═════╝ ╚══════╝ +# +# Long moves are faster than the small ones and used for the bulk of the bowden movement. You can set two fast load speeds +# depending on whether pulling from the spool or filament buffer (if fitted and not the first time load). This can be helpful +# in allowing faster loading from buffer and slower when pulling from the spool because of the additional friction (prevents +# loosing steps). Unloading speed can be tuning if you have a rewinder system that imposes additional limits. +# NOTE: Encoder cannot keep up much above 450mm/s so make sure 'bowden_apply_correction' is off at very high speeds! +# +gear_from_spool_speed: 80 # mm/s Speed when loading from the spool (for the first time if has_filament_buffer: 1) +gear_from_spool_accel: 100 # Acceleration when loading from spool +gear_from_buffer_speed: 150 # mm/s Speed when loading filament from buffer. Conservative is 100mm/s, Max around 400mm/s +gear_from_buffer_accel: 400 # Normal acceleration when loading filament +gear_unload_speed: 80 # mm/s Use (lower) speed when unloading filament (defaults to "from spool" speed) +gear_unload_accel: 100 # Acceleration when unloading filament (defaults to "from spool" accel) +# +gear_short_move_speed: 80 # mm/s Speed when making short moves (like incremental retracts with encoder) +gear_short_move_accel: 600 # Usually the same as gear_from_buffer_accel (for short movements) +gear_short_move_threshold: 70 # Move distance that controls application of 'short_move' speed/accel +gear_homing_speed: 50 # mm/s Speed of gear stepper only homing moves (e.g. homing to gate or extruder) + +# Speeds of extruder movement. The 'sync' speeds will be used when gear and extruder steppers are moving in sync +# +extruder_load_speed: 16 # mm/s speed of load move inside extruder from homing position to meltzone +extruder_unload_speed: 16 # mm/s speed of unload moves inside of extruder (very initial move from meltzone is 50% of this) +extruder_sync_load_speed: 18 # mm/s speed of synchronized extruder load moves +extruder_sync_unload_speed: 18 # mm/s speed of synchronized extruder unload moves +extruder_homing_speed: 18 # mm/s speed of extruder only homing moves (e.g. to toolhead sensor) + +# When Happy Hare calls out to a macro for user customization and for parking moves these settings are applied and the previous +# values automatically restored afterwards. This allows for deterministic movement speed regardless of the starting state. +# +macro_toolhead_max_accel: 0 # Default printer toolhead acceleration applied when macros are run. 0 = use printer max +macro_toolhead_min_cruise_ratio: 0.5 # Default printer cruise ratio applied when macros are run + + +# Gate loading/unloading ----------------------------------------------------------------------------------------------- +# ██████╗ █████╗ ████████╗███████╗ ██╗ ██████╗ █████╗ ██████╗ +# ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██║ ███╗███████║ ██║ █████╗ ██║ ██║ ██║███████║██║ ██║ +# ██║ ██║██╔══██║ ██║ ██╔══╝ ██║ ██║ ██║██╔══██║██║ ██║ +# ╚██████╔╝██║ ██║ ██║ ███████╗ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +# These settings control the loading and unloading filament at the gate which is the parking position inside the MMU. +# Typically this would be switch sensor but you can also use an encoder. Even with encoder the endstop can be a switch +# and the encoder used for move verifcation (see advanced 'gate_endstop_to_encoder' option). Note that the `encoder` +# method, due to the nature of its operation will overshoot a little. This is not a problem in practice because the +# overshoot will simply be compensated for in the subsequent move. A +ve parking distance moves towards the MMU, -ve +# moves back through the endstop towards the toolhead. If the MMU has multiple bowden tubes then it is possible to home +# at the extruder sensor and avoid long bowden moves! +# +# Possible gate_homing_endstop names: +# encoder - Detect filament position using movement of the encoder +# mmu_gate - Use gate endstop +# mmu_gear - Use individual per-gate endstop (type-B MMU's) +# extruder - Use extruder entry sensor (Only for some type-B designs, see [mmu_machine] require_bowden_move setting) +# +gate_homing_endstop: encoder # Name of gate endstop, "encoder" forces use of encoder for parking +gate_homing_max: 70 # Maximum move distance to home to the gate (or actual move distance for encoder parking) +gate_preload_homing_max: 70 # Maximum homing distance to the mmu_gear endstop (if MMU is fitted with one) +gate_preload_parking_distance: 0 # Parking position relative to mmu_gear endstop (-ve value means move forward) +gate_unload_buffer: 50 # Amount to reduce the fast unload so that filament doesn't overshoot when parking +gate_parking_distance: 23 # Parking position in the gate (distance back from homing point, -ve value means move forward) +gate_endstop_to_encoder: 10 # Distance between gate endstop and encoder (IF both fitted. +ve if encoder after endstop) +gate_autoload: 1 # If pre-gate sensor fitted this controls the automatic loading of the gate +gate_final_eject_distance: 0 # Distance to eject filament on MMU_EJECT (Ignored by MMU_UNLOAD) + + +# Bowden tube loading/unloading ---------------------------------------------------------------------------------------- +# ██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ +# ██╔══██╗██╔═══██╗██║ ██║██╔══██╗██╔════╝████╗ ██║ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██████╔╝██║ ██║██║ █╗ ██║██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║███████║██║ ██║ +# ██╔══██╗██║ ██║██║███╗██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██║██║ ██║ +# ██████╔╝╚██████╔╝╚███╔███╔╝██████╔╝███████╗██║ ╚████║ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +bowden_homing_max: 2000 # Maximum attempted bowden move (for calibration). Should be larger than your actual bowden! + +# If you MMU is equiped with an encoder the following options are available: +# +# In addition to different bowden loading speeds for buffer and non-buffered filament it is possible to detect missed +# steps caused by "jerking" on a heavy spool. If bowden correction is enabled Happy Hare will "believe" the encoder +# reading and make correction moves to bring the filament to within the 'bowden_allowable_load_delta' of the end of +# bowden position (this does require a reliable encoder and is not recommended for very high speed loading >350mm/s) +# +bowden_apply_correction: 0 # 1 to enable, 0 disabled +bowden_allowable_load_delta: 20.0 # How close in mm the correction moves will attempt to get to target +# +# This saftey check uses the encoder to verify the filament is free of extruder before the fast bowden movement to +# reduce possibility of grinding filament. If enabled the trigger can be tuned by setting the "error tolerance" which +# represents the fraction of allowable mismatch between actual movement and that seen by encoder. Setting to 50% tolerance +# usually works well. Increasing will make test more tolerant. Value of 100% essentially disables error detection +# +bowden_pre_unload_test: 1 # 1 to check for bowden movement before full pull (slower), 0 don't check (faster) +bowden_pre_unload_error_tolerance: 50 # ADVANCED: tune pre_unload_test + + +# Extruder homing ----------------------------------------------------------------------------------------------------- +# ███████╗██╗ ██╗████████╗ ██╗ ██╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ +# ██╔════╝╚██╗██╔╝╚══██╔══╝ ██║ ██║██╔═══██╗████╗ ████║██║████╗ ██║██╔════╝ +# █████╗ ╚███╔╝ ██║ ███████║██║ ██║██╔████╔██║██║██╔██╗ ██║██║ ███╗ +# ██╔══╝ ██╔██╗ ██║ ██╔══██║██║ ██║██║╚██╔╝██║██║██║╚██╗██║██║ ██║ +# ███████╗██╔╝ ██╗ ██║██╗ ██║ ██║╚██████╔╝██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ +# ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# Happy Hare needs a reference "homing point" close to the extruder from which to accurately complete the loading of +# the toolhead. This homing operation takes place after the fast bowden load and it is anticipated that that load +# operation will leave the filament just shy of the homing point. If using a toolhead sensor this initial extruder +# homing is unnecessary (but can be forced) because the homing will occur inside the extruder for the optimum in accuracy. +# You still should set this homing method because it is also used for the determination and calibration of bowden length. +# +# In addition to an entry sensor "extruder" it is possible for Happy Hare to "feel" for the extruder gear entry +# by colliding with it. This can be done with encoder based collision detection, the compression of the sync-feedback +# (aka buffer) sensor or using "touch" (stallguard) on the gear stepper. Note that encoder collision detection is not +# completely deterministic and you will have to find the sweetspot for your setup by adjusting the TMC current reduction. +# Note that reduced current during collision detection can also prevent unecessary filament griding. +# +# Possible extruder_homing_endtop names: +# collision - Detect the collision with the extruder gear by monitoring encoder movement (Requires encoder) +# Fast bowden load will move to the extruder gears +# mmu_gear_touch - Use touch detection when the gear stepper hits the extruder (Requires stallguard) +# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home +# extruder - If you have a "filament entry" endstop configured (Requires 'extruder' endstop) +# Fast bowden load will move to extruder_homing_buffer distance before sensor, then home +# filament_compression - If you have a "sync-feedback" sensor with compression switch configured +# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home +# none - Don't attempt to home. Only possibiliy if lacking all sensor options +# Fast bowden load will move to the extruder gears. Option is fine if using toolhead sensor +# Note: The homing_endstop will be ignored ("none") if a toolhead sensor is available unless "extruder_force_homing: 1" +# +extruder_homing_max: 80 # Maximum distance to advance in order to attempt to home the extruder +extruder_homing_endstop: collision # Filament homing method/endstop name (fallback if toolhead sensor not available) +extruder_homing_buffer: 25 # Amount to reduce the fast bowden load so filament doesn't overshoot the extruder homing point +extruder_collision_homing_current: 30 # % gear_stepper current (10%-100%) to use when homing to extruder homing (100 to disable) + +# If you have a toolhead sensor it will always be used as a homing point making the homing outside of the extruder +# potentially unnecessary. However you can still force this initial homing step by setting this option in which case +# the filament will home to the extruder and then home to the toolhead sensor in two steps +# +extruder_force_homing: 0 + + +# Toolhead loading and unloading -------------------------------------------------------------------------------------- +# ████████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗███████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ +# ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██║ ██║ ██║██║ ██║██║ ███████║█████╗ ███████║██║ ██║ ██║ ██║ ██║███████║██║ ██║ +# ██║ ██║ ██║██║ ██║██║ ██╔══██║██╔══╝ ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║ ██║ +# ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║███████╗██║ ██║██████╔╝ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +# It is possible to define highly customized loading and unloading sequences, however, unless you have a specialized +# setup it is probably easier to opt for the built-in toolhead loading and unloading sequence which already offers a +# high degree of customization. If you need even more control then edit the _MMU_LOAD_SEQUENCE and _MMU_UNLOAD_SEQUENCE +# macros in mmu_sequence.cfg - but be careful! +# +# An MMU must have a known point at the end of the bowden from which it can precisely load the extruder. Generally this +# will either be the extruder entrance (which is controlled with settings above) or by homing to toolhead sensor. If +# you have toolhead sensor it is past the extruder gear and the driver needs to know the max distance (from end of +# bowden move) to attempt homing +# +toolhead_homing_max: 40 # Maximum distance to advance in order to attempt to home to defined homing endstop + +# IMPORTANT: These next three settings are based on the physical dimensions of your toolhead +# Once a homing position is determined, Happy Hare needs to know the final move distance to the nozzle. There is only +# one correct value for your setup - use 'toolhead_ooze_reduction' (which corresponds to the residual filament left in +# your nozzle) to control excessive oozing on load. See doc for table of proposed values for common configurations. +# +# NOTE: If you have a toolhead sensor you can automate the calculation of these parameters! Read about the +# `MMU_CALIBRATE_TOOLHEAD` command (https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing#---calibrating-toolhead) +# +toolhead_extruder_to_nozzle: 72 # Distance from extruder gears (entrance) to nozzle +toolhead_sensor_to_nozzle: 62 # Distance from toolhead sensor to nozzle (ignored if not fitted) +toolhead_entry_to_extruder: 8 # Distance from extruder "entry" sensor to extruder gears (ignored if not fitted) + +# This setting represents how much residual filament is left behind in the nozzle when filament is removed, it is thus +# used to reduce the extruder loading length and prevent excessive blobbing but also in the calculation of purge volume. +# Note that this value can also be measured with the `MMU_CALIBRATE_TOOLHEAD` procedure +# +toolhead_residual_filament: 0 # Reduction in extruder loading length because of residual filament left behind + +# TUNING: Finally, this is the last resort tuning value to fix blobbing. It is expected that this value is NEAR ZERO as +# it represents a further reduction in extruder load length to fix blobbing. If using a wipetower and you experience blobs +# on it, increase this value (reduce the quantity of filament loaded). If you experience gaps, decrease this value. If gaps +# and already at 0 then perhaps the 'toolhead_extruder_to_nozzle' or 'toolhead_residual_filament' settings are incorrect. +# Similarly a value >+5mm also suggests the four settings above are not correct. Also see 'retract' setting in +# 'mmu_macro_vars.cfg' for final in-print ooze tuning. +# +toolhead_ooze_reduction: 0 # Reduction in extruder loading length to prevent ooze (represents filament remaining) + +# Distance added to the extruder unload movement to ensure filament is free of extruder. This adds some degree of tolerance +# to slightly incorrect configuration or extruder slippage. However don't use as an excuse for incorrect toolhead settings +# +toolhead_unload_safety_margin: 10 # Extra movement safety margin (default: 10mm) + +# If not synchronizing gear and extruder and you experience a "false" clog detection immediately after the tool change +# it might be because of a long bowden and/or large internal diameter that causes slack in the filament. This optional +# move will tighten the filament after a load by % of current clog detection length. Gear stepper will run at 50% current +# +toolhead_post_load_tighten: 60 # % of clog detection length, 0 to disable. Ignored if 'sync_to_extruder: 1' + +# If synchronizing gear and extruder and you have a sync-feedback "buffer" this setting determines whether to use it +# to create neutral tension after loading +toolhead_post_load_tension_adjust: 1 # 1 to enable (recommended), 0 to disable + +# If sync-feedback compression sensor is available this test will ensure the filament passes the extruder entry by checking +# for neutral tension when moving filament with just the extruder. Recommended with sprung loaded sync-feedback buffers. +# This is ignored if toolhead sensor is available. +toolhead_entry_tension_test: 1 # 1 to enable (recommended), 0 to disable + +# ADVANCED: Controls the detection of successful extruder load/unload movement and represents the fraction of allowable +# mismatch between actual movement and that seen by encoder. Setting to 100% tolerance effectively turns off checking. +# Some designs of extruder have a short move distance that may not be picked up by encoder and cause false errors. This +# allows masking of those errors. However the error often indicates that your extruder load speed is too high or the +# friction is too high on the filament and in that case masking the error is not a good idea. Try reducing friction +# and lowering speed first! +# +toolhead_move_error_tolerance: 60 + + +# Tip forming --------------------------------------------------------------------------------------------------------- +# ████████╗██╗██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ +# ╚══██╔══╝██║██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ +# ██║ ██║██████╔╝ █████╗ ██║ ██║██████╔╝██╔████╔██║██║██╔██╗ ██║██║ ███╗ +# ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██║ ██║ +# ██║ ██║██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# Tip forming responsibility can be split between slicer (in-print) and standalone macro (not in-print) or forced to always +# be done by Happy Hare's standalone macro. Since you always need the option to form tips without the slicer so it is +# generally easier to completely turn off the slicer, force "standalone" tip forming and tune only in Happy Hare. +# +# When Happy Hare is asked to form a tip it will run the referenced macro. Two are reference examples are provided but +# you can implement your own: +# _MMU_FORM_TIP .. default tip forming similar to popular slicers like Superslicer and Prusaslicer +# _MMU_CUT_TIP .. for Filametrix (originally ERCFv2) or similar style toolhead filament cutting system +# +# NOTE: For MMU located cutting like the optional EREC cutter you should set still this to _MMU_FORM_TIP to build a decent +# tip prior to extraction and cutting after the unload. +# +# Often it is useful to increase the extruder current for the rapid movement to ensure high torque and no skipped steps +# +# If opting for slicer tip forming you MUST configure where the slicer leaves the filament in the extruder since +# there is no way to determine this. This can be ignored if all tip forming is performed by Happy Hare +# +force_form_tip_standalone: 1 # 0 = Slicer in print else standalone, 1 = Always standalone tip forming (TURN SLICER OFF!) +form_tip_macro: _MMU_FORM_TIP # Name of macro to call to perform the tip forming (or cutting) operation +extruder_form_tip_current: 100 # % of extruder current (100%-150%) to use when forming tip (100 to disable) +slicer_tip_park_pos: 0 # This specifies the position of filament in extruder after slicer completes tip forming + + +# Purging ------------------------------------------------------------------------------------------------------------- +# ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ +# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██║████╗ ██║██╔════╝ +# ██████╔╝██║ ██║██████╔╝██║ ███╗██║██╔██╗ ██║██║ ███╗ +# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║ +# ██║ ╚██████╔╝██║ ██║╚██████╔╝██║██║ ╚████║╚██████╔╝ +# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# After a toolchange it is necessary to purge the old filament. Similar to tip forming this can be done by the slicer and/or +# by Happy Hare using an extension like Blobifer. If a purge_macro is defined it will be called when not printing or whenever +# the slicer isn't going to purge (like initial tool load). You can force it to always be called in a print by setting +# force_purge_standalone, but remember to turn off the slicer wipetower +# +# The default is for no (empty) macro so purging will not be done out of a print and thus wipetower. Two options are shipped with +# Happy Hare but you can also build your own custom one: +# _MMU_PURGE .. default purging that just dumps the desired amount of filament (setup correct parking before enabling this!) +# BLOBIFER .. for excellent Blobifer addon (https://github.com/Dendrowen/Blobifier) +# +# Often it is useful to increase the extruder current for the often rapid puring movement to ensure high torque and no skipped steps +# +force_purge_standalone: 0 # 0 = Slicer wipetower in print else standalone, 1 = Always standalone purging (TURN WIPETOWER OFF!) +purge_macro: _MMU_PURGE # Name of macro to call to perform the standalone purging operation. E.g. BLOBIFIER, _MMU_PURGE +extruder_purge_current: 100 # % of extruder current (100%-150%) to use when purging (100 to disable) + + +# Synchronized gear/extruder movement ---------------------------------------------------------------------------------- +# ███╗ ███╗ ██████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗███╗ ██╗ ██████╗ +# ████╗ ████║██╔═══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██╔════╝╚██╗ ██╔╝████╗ ██║██╔════╝ +# ██╔████╔██║██║ ██║ ██║ ██║ ██║██████╔╝ ███████╗ ╚████╔╝ ██╔██╗ ██║██║ +# ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║██╔══██╗ ╚════██║ ╚██╔╝ ██║╚██╗██║██║ +# ██║ ╚═╝ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║ ███████║ ██║ ██║ ╚████║╚██████╗ +# ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ +# +# This controls whether the extruder and gear steppers are synchronized during printing operations +# If you normally run with maxed out gear stepper current consider reducing it with 'sync_gear_current' +# If equipped with TMC drivers the current of the gear and extruder motors can be controlled to optimize performance. +# This can be useful to control gear stepper temperature when printing with synchronized motor +# +sync_to_extruder: 0 # Gear motor is synchronized to extruder during print +sync_gear_current: 70 # % of gear_stepper current (10%-100%) to use when syncing with extruder during print +sync_form_tip: 0 # Synchronize during standalone tip formation (initial part of unload) +sync_purge: 0 # Synchronize during standalone purging (last part of load) + +# Optionally it is possible to leverage feedback from a "compression/expansion" sensor (aka "buffer") in the bowden +# path from MMU to extruder to ensure that the two motors are kept in sync as viewed by the filament (the signal feedback +# state can be binary supplied by one or two switches: -1 (expanded) and 1 (compressed) of proportional value between +# -1.0 and 1.0. +# +# If only "one half" of the sync-feedback is available (either compression-only or tension-only) then the rotation +# distance is always shifted based on the high/low multipliers, however if both tension and compression are available +# then the rotation distance will autotune to correct setting (recommend you also enable 'autotune_rotation_distance: 1' +# Note that proportional feedback sensors are continuously dynamic +# +# Possible buffer setups, forth option for type where neutral is when both sensors are active: +# +# <------maxrange------> <------maxrange------> <------maxrange------> <------maxrange------> +# <--range---> <----range-----> <----range-----> <> range=0 +# |====================| |====================| |====================| |====================| +# ^ ^ ^ ^ ^^ +# compression tension compression-only tension-only +# +sync_feedback_enabled: 0 # Turn off even if sensor is installed and active +sync_feedback_buffer_range: 6 # Travel in "buffer" between compression/tension or one sensor and end (see above) +sync_feedback_buffer_maxrange: 12 # Absolute maximum end-to-end travel (mm) provided by buffer (see above) +sync_feedback_speed_multiplier: 5 # % "twolevel" gear speed delta to keep filament neutral in buffer (recommend 5%) +sync_feedback_boost_multiplier: 3 # % "twolevel" extra gear speed boost for finding initial neutral position (recommend 3%) +sync_feedback_extrude_threshold: 5 # Extruder movement (mm) for updates (keep small but set > retract distance) + +# If defined this forces debugging to a telemetry log file "sync_.jsonl". This is great if trying to tune clog/tangle +# detection or for getting help on the Happy Hare forum. To plot graph of sync-feedback operation, run: +# ~/Happy-Hare/utils/plot_sync_feedback.sh +# +sync_feedback_debug_log: 0 # 0 = disable (normal opertion), 1 = enable telemetry log (for debugging) + + +# ESpooler control ----------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ +# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ +# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ +# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ +# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ +# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ +# +# If your MMU has a dc motor (often N20) controlled respooler/assist then how it operates can be controlled with these +# settings. Typically the espooler will be controlled with PWM signal. This will be at the maximum at speeds equal or +# above 'espooler.max_stepper_speed'. The PWM signal will scale downwards towards 0 for slower speeds. The falloff being +# controlled by the 'espooler_speed_exponent' setting according to this formula and allows for non-linear characteristics +# the DC motor (0.5 is a good starting value). +# +# espooler_pwm = (stepper_speed / espooler_max_stepper_speed) ^ {espooler_speed_exponent} +# +# Regardless of h/w configuration you can enable/disable actions with the 'espooler_operations' list. E.g. remove 'play' to +# turn off operation while printing. Options are: +# +# rewind - when filament is being unloaded under MMU control (aka respool) +# assist - when filament is being loaded under MMU control (% of "rewind" speed but with minimum of "print" power) +# print - while printing. Generally set 'espooler_printing_power' to a low percentage just to allow motor to be turned +# freely or set to 0 to enable/allow "burst" assist movements +# +# If using a digitally controlled espooler motor (not PWM) then you should turn off the "print" mode and set +# 'espooler_min_stepper_speed' to prevent "over movement" +# +espooler_min_distance: 30 # Individual stepper movements less than this distance will not active espooler +espooler_max_stepper_speed: 300 # Gear stepper speed at which espooler will be at maximum power +espooler_min_stepper_speed: 0 # Gear stepper speed at which espooler will become inactive (useful for non PWM control) +espooler_speed_exponent: 0.5 # Controls non-linear espooler power relative to stepper speed (see notes) +espooler_assist_reduced_speed: 50 # Control the % of the rewind speed that is applied to assisting load (want rewind to be faster) +espooler_printing_power: 0 # If >0, fixes the % of PWM power while printing. 0=allows burst movement +espooler_operations: rewind, assist, print # List of operational modes (allows disabling even if h/w is configured) +# +# The following burst configuration is used to control the small rotation in the ASSIST direction optionally used +# when in 'print' operation is enabled, 'espooler_printing_power: 0' and is triggered (tension switch or extruder movement). +# It can also be used to loosen filament with 'MMU_ESPOOLER COMMAND=assist BURST=1' +# +espooler_assist_extruder_move_length: 100 # Distance (mm) extruder needs to move between each assist burst +espooler_assist_burst_power: 100 # The % power of the burst move +espooler_assist_burst_duration: 0.4 # The duration of the burst move is seconds +espooler_assist_burst_trigger: 0 # If trigger assist switch is fitted 0=disable, 1=enable +espooler_assist_burst_trigger_max: 3 # If trigger assist switch is fitted this limits the max number of back-to-back advances +# +# The following burst configuration is used to control the small rotation in the REWIND direction optionally used +# when running running the filament drying cycle. The goal is to rotate the spool 60-90 degrees. It can also be +# used to tighten the filament with 'MMU_ESPOOLER COMMAND=rewind BURST=1' +# +espooler_rewind_burst_power: 100 # The % power of the rewind burst move +espooler_rewind_burst_duration: 0.4 # The duration of the rewind burst move is seconds + + +# Heater / Environment Management ------------------------------------------------------------------------------------ +# ██╗ ██╗███████╗ █████╗ ████████╗███████╗██████╗ +# ██║ ██║██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ +# ███████║█████╗ ███████║ ██║ █████╗ ██████╔╝ +# ██╔══██║██╔══╝ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ +# ██║ ██║███████╗██║ ██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +heater_max_temp: 70 # Absolute max heater setting to protect the MMU enclosure construction (adjust to match material) +heater_default_dry_temp: 45 # Default drying temperature if filament type is not matched in drying_data +heater_default_dry_time: 300 # Default drying cycle time in minutes +heater_default_humidity: 25 # Default humidity % goal. Drying will terminate if this value is reached +heater_vent_macro: _MMU_VENT # Name of macro to periodicaly call during drying cycle +heater_vent_interval: 0 # Interval in minutes to call heater_vent_macro during drying cycle, 0=disable venting +heater_rotate_interval: 5 # Interval in minutes to rotate filament (requires eSpooler and filament end attached to spool) + +# Drying data for MMU_HEATER DRY=1 command in form (material type is case insensitive): +# 'filament_type': (temp, drying_time_mins) +# +# (Careful with formatting of this line - reformatting will break upgrade logic) +# +drying_data: { 'pla': (45, 300), 'pla+': (55, 300), 'petg': (60, 300), 'tpu': (55, 300), 'abs': (70, 300), 'abs+': (75, 300), 'asa': (65, 300), 'nylon': (75, 600), 'pc': (75, 600), 'pva': (75, 600), 'hips': (75, 600) } + + +# FlowGuard Clog and Tangle Detection -------------------------------------------------------------------------------- +# ███████╗██╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ +# ██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗ +# █████╗ ██║ ██║ ██║██║ █╗ ██║██║ ███╗██║ ██║███████║██████╔╝██║ ██║ +# ██╔══╝ ██║ ██║ ██║██║███╗██║██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║ +# ██║ ███████╗╚██████╔╝╚███╔███╔╝╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝ +# ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ +# +# Options are available to automatically detects extruder clogs and MMU tangles. Each option works independently and +# can be combined. Flowguard can even discern the difference between an extruder clog and a spool tangle! +# +# Flowguard: This intelligently measures filament tension (only available if sync-feedback buffer is fitted) +# +# Encoder detection: This monitors encoder movement and compares to extruder (only available if encoder is fitted) +# +flowguard_enabled: 1 # 0 = Flowguard protection disabled, 1 = Enabled + +# The flowguard_max_relief is the amount of relief movement (effective mm change in filament length between MMU and extruder) +# that Happy Hare will wait until triggering a clog or runout. A smaller value is more sensitive to triggering. Since the +# relief movement is hightly dependent on filament "spring" in the bowden tube, filament friction, and +# 'sync_feedback_buffer_range', it is generally good to start high and then decrease if a more sensitive trigger is desired. +# Analog proportional (type P) sensors can generally have a much lower value. Increase if you have false triggers. +flowguard_max_relief: 40 + +# Encoder runout/clog/tangle detection watches for movement over either a static or automatically adjusted distance - if +# no encoder movement is seen when the extruder moves this distance runout/ clog/tangle event will be generated. Allowing +# the distance to be adjusted automatically (mode=2) will generally allow for a quicker trigger but use a static length +# (mode=1, set encoder_max_motion) if you get false triggers (see flowguard guide on wiki for more details). +# Note that this feature cannot disinguish between clog or tangle. +flowguard_encoder_mode: 2 # 0 = Disable, 1 = Static length clog detection, 2 = Automatic length clog detection + +# The encoder_max_motion is the absolute max permitted extruder movement without the encoder seeing movement when using +# status mode (mode=1). Smaller values are more sensitive but beware of going too small - slack and friction in the +# bowden may cause gaps in encoder movement. Increase if you have false triggers. +# Note that this value is overriden by any calibrated value stored in 'mmu_vars.cfg' if in automatic mode (mode=2). +flowguard_encoder_max_motion: 20 + + +# Filament Management Options ---------------------------------------------------------------------------------------- +# ███████╗██╗██╗ ███╗ ███╗ ██████╗ ███╗ ███╗████████╗ +# ██╔════╝██║██║ ████╗ ████║██╔════╝ ████╗ ████║╚══██╔══╝ +# █████╗ ██║██║ ██╔████╔██║██║ ███╗██╔████╔██║ ██║ +# ██╔══╝ ██║██║ ██║╚██╔╝██║██║ ██║██║╚██╔╝██║ ██║ +# ██║ ██║███████╗██╗ ██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ +# +# - EndlessSpool feature allows detection of runout on one spool and the automatic mapping of tool to an alternative +# gate (spool). Set to '1', this feature requires clog detection or gate sensor or pre-gate sensors. EndlessSpool +# functionality can optionally be extended to attempt to load an empty gate with 'endless_spool_on_load'. On some MMU +# designs (with linear selector) it can also be configured to eject filament remains to a designated gate rather than +# defaulting to current gate. A custom gate will disable pre-gate runout detection for EndlessSpool because filament +# end must completely pass through the gate for selector to move +# +endless_spool_enabled: 1 # 0 = disable, 1 = enable endless spool +endless_spool_on_load: 0 # 0 = don't apply endless spool on load, 1 = run endless spool if gate is empty +endless_spool_eject_gate: -1 # Which gate to eject the filament remains. -1 = current gate +#endless_spool_groups: # Default EndlessSpool groups (see later in file) +# +# Spoolman support requires you to correctly enable spoolman with moonraker first. If enabled, the gate SpoolId will +# be used to load filament details and color from the spoolman database and Happy Hare will activate/deactivate +# spools as they are used. The enabled variation allows for either the local map or the spoolman map to be the +# source of truth as well as just fetching filament attributes. See this table for explanation: +# +# | Activate/ | Fetch filament attributes | Filament gate | Filament gate | +# spoolman_support | Deactivate | attributes from spoolman | assignment shown | assignment pulled | +# | spool? | based on spool_id? | in spoolman db? | from spoolman db? | +# -----------------+------------+---------------------------+------------------+-------------------+ +# off | no | no | no | no | +# readonly | yes | yes | no | no | +# push | yes | yes | yes | no | +# pull | yes | yes | yes | yes | +# +spoolman_support: off # off = disabled, readonly = enabled, push = local gate map, pull = remote gate map +pending_spool_id_timeout: 20 # Seconds after which this pending spool_id (set with rfid) is voided +# +# Mainsail/Fluid UI can visualize the color of filaments next to the extruder/tool chooser. The color is dynamic and +# can be customized to your choice: +# +# slicer - Color from slicer tool map (what the slicer expects) +# allgates - Color from all the tools in the gate map after running through the TTG map +# gatemap - As per gatemap but hide empty tools +# off - Turns off support +# +# Note: Happy Hare will also add the 'spool_id' variable to the Tx macro if spoolman is enabled +# +t_macro_color: slicer # 'slicer' = default | 'allgates' = mmu | 'gatemap' = mmu without empty gates | 'off' + + +# Print Statistics --------------------------------------------------------------------------------------------------- +# ███████╗████████╗ █████╗ ████████╗███████╗ +# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ +# ███████╗ ██║ ███████║ ██║ ███████╗ +# ╚════██║ ██║ ██╔══██║ ██║ ╚════██║ +# ███████║ ██║ ██║ ██║ ██║ ███████║ +# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ +# +# These parameters determine how print statistic data is shown in the console. This table can show a lot of data, +# probably more than you'd want to see. Below you can enable/disable options to your needs. +# +# +-----------+---------------------+----------------------+----------+ +# | 114(46) | unloading | loading | complete | +# | swaps | pre | - | post | pre | - | post | swap | +# +-----------+------+-------+------+------+-------+-------+----------+ +# | all time | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | +# | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | +# | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | +# | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | +# | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | +# +-----------+------+-------+------+------+-------+-------+----------+ +# Note: Only formats correctly on Python3 +# +# Comma separated list of desired columns +# Options: pre_unload, form_tip, unload, post_unload, pre_load, load, purge, post_load, total +console_stat_columns: unload, load, post_load, total + +# Comma separated list of rows. The order determines the order in which they're shown. +# Options: total, total_average, job, job_average, last +console_stat_rows: total, total_average, job, job_average, last + +# How you'd want to see the state of the gates and how they're performing +# string - poor, good, perfect, etc.. +# percentage - rate of success +# emoticon - fun sad to happy faces (python3 only) +console_gate_stat: emoticon + +# Always display the full statistics table +console_always_output_full: 1 # 1 = Show full table, 0 = Only show totals out of print + + +# Calibration and autotune ------------------------------------------------------------------------------------------- +# ██████╗ █████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ +# ██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ +# ██║ ███████║██║ ██║██████╔╝██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ +# ██║ ██╔══██║██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ +# ╚██████╗██║ ██║███████╗██║██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ +# ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ +# +# These are auto calibration/tuning settings that can be used to ease initial setup and/or to tune calibration over +# time based on measured telemetry. Whether these auto-tuning features are available depends on MMU design and +# configured sensors (explained below). The setting will be ignored if the required sensors are not available but if +# they can operate they will suppress the normal calibration warnings (MMU_STATUS can still be used to view them). +# Note that these are initially set by the installer to recommended values +# +# autocal_bowden_length - the calibrated bowden length will be established on first load. It can also be set +# manually or reset with MMU_CALIBRATE_BOWDEN. Best results require the use of +# sync-feedback-compression or extruder sensor but gear-touch or encoder will also work. +# 'extruder_homing_endstop' cannot be 'none' +# autotune_bowden_length - Once calibrated this setting will tune the bowden distance over time. Works best with +# toolhead sensor +# skip_cal_rotation_distance - This will rely on installed default value (although it can still be calibrated). Usually +# a good choice if autotune is enabled +# autotune_rotation_distance - Requires sync-feedback sensor (aka "buffer") or calibrated encoder. If set then either the +# "autotuner" (sync-feedback buffer) or encoder telemetry will be used to adjust the +# persisted gear rotation distance. +# skip_cal_encoder - Will rely on installed default value (although it can still be calibrates). +# skip_cal_encoder - Will rely on installed default value (although it can still be calibrates). +# Not recommended but allows for easier initial setup especially when 'autotune_encoder' +# is enabled. +# autotune_encoder - NOT IMPLEMENTED YET. Soon! +# +autocal_bowden_length: 1 # Automated bowden length calibration. 1=automatic, 0=manual/off +autotune_bowden_length: 1 # Automated bowden length tuning. 1=on, 0=off +skip_cal_rotation_distance: 0 # Skip rotation distance calibration (MMU_CALIBRATE_GEAR), 1=skip, 0=require +autotune_rotation_distance: 0 # Automated gate calibration/tuning. 1=automatic, 0=manual/off +skip_cal_encoder: 0 # Skip encoder calibration (MMU_CALIBRATE_ENCODER), 1=skip, 0=require +autotune_encoder: 0 # Automated encoder tuning. 1=automatic, 0=manual/off + + +# Miscellaneous, but you should review ------------------------------------------------------------------------------- +# ███╗ ███╗██╗███████╗ ██████╗ +# ████╗ ████║██║██╔════╝██╔════╝ +# ██╔████╔██║██║███████╗██║ +# ██║╚██╔╝██║██║╚════██║██║ +# ██║ ╚═╝ ██║██║███████║╚██████╗ +# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ +# +# Important you verify these work for you setup/workflow. Temperature and timeouts +# +timeout_pause: 72000 # Idle time out (printer shuts down) in seconds used when in MMU pause state +disable_heater: 600 # Delay in seconds after which the hotend heater is disabled in the MMU_PAUSE state +default_extruder_temp: 200 # Default temperature for performing swaps and forming tips when not in print (overridden by gate map) +extruder_temp_variance: 2 # When waiting for extruder temperature this is the +/- permissible variance in degrees (>= 1) +# +# Other workflow options +# +startup_home_if_unloaded: 0 # 1 = force mmu homing on startup if unloaded, 0 = do nothing +startup_reset_ttg_map: 0 # 1 = reset TTG map on startup, 0 = do nothing +show_error_dialog: 1 # 1 = show pop-up dialog in addition to console message, 0 = show error in console +preload_attempts: 5 # How many "grabbing" attempts are made to pick up the filament with preload feature +strict_filament_recovery: 0 # If enabled with MMU with toolhead sensor, this will cause filament position recovery to + # perform extra moves to look for filament trapped in the space after extruder but before sensor +filament_recovery_on_pause: 1 # 1 = Run a quick check to determine current filament position on pause/error, 0 = disable +retry_tool_change_on_error: 0 # Whether to automatically retry a failed tool change. If enabled Happy Hare will perform + # the equivalent of 'MMU_RECOVER' + 'Tx' commands which usually is all that is necessary + # to recover. Note that enabling this can mask problems with your MMU +bypass_autoload: 1 # If extruder sensor fitted this controls the automatic loading of extruder for bypass operation +has_filament_buffer: 1 # Whether the MMU has a filament buffer. Set to 0 if using Filamentalist or DC eSpooler, etc +# +# Advanced options. Don't mess unless you fully understand. Read documentation. +# +encoder_move_validation: 1 # ADVANCED: 1 = Normally Encoder validates move distances are within given tolerance + # 0 = Validation is disabled (eliminates slight pause between moves but less safe) +print_start_detection: 1 # ADVANCED: Enabled for Happy Hare to automatically detect start and end of print and call + # ADVANCED: MMU_PRINT_START and MMU_PRINT_END automatically. Harmless to leave enabled but can disable + # if you think it is causing problems and known START/END is covered in your macros +extruder: extruder # ADVANCED: Name of the toolhead extruder that MMU is using +gcode_load_sequence: 0 # VERY ADVANCED: Gcode loading sequence 1=enabled, 0=internal logic (default) +gcode_unload_sequence: 0 # VERY ADVANCED: Gcode unloading sequence, 1=enabled, 0=internal logic (default) + + +# ADVANCED: Klipper tuning ------------------------------------------------------------------------------------------- +# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ +# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ +# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ +# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ +# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +# Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing +# operations especially so with CANbus connected MCUs. Happy Hare uses many homing moves for reliable extruder loading +# and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error +# +update_trsync: 0 # 1 = Increase TRSYNC_TIMEOUT, 0 = Leave the klipper default +# +# Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms timeout will kill +# the print. Since it seems to occur only on homing moves they can be safely retried to workaround. This has been +# working well in practice +canbus_comms_retries: 3 # Number of retries. Recommend the default of 3. +# +# Older neopixels have very finicky timing and can generate lots of "Unable to obtain 'neopixel_result' response" +# errors in klippy.log. An often cited workaround is to increase BIT_MAX_TIME in neopixel.py. This option does that +# automatically for you to save dirtying klipper +update_bit_max_time: 1 # 1 = Increase BIT_MAX_TIME, 0 = Leave the klipper default +# +# BTT ViViD used a AHT30 sensor. If you are using an older klipper you may not have this sensor available. If so, use +# AHT10 and set this to 1 to convert AHT30 commands to AHT10 +update_aht10_commands: 0 # 1 = Config AHT10 for BTT ViViD heater sensor on older klipper, 0 = Leave the klipper default + + +# ADVANCED: MMU macro overrides --- ONLY SET IF YOU'RE COMFORTABLE WITH KLIPPER MACROS ------------------------------- +# ███╗ ███╗ █████╗ ██████╗██████╗ ██████╗ ███████╗ +# ████╗ ████║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔════╝ +# ██╔████╔██║███████║██║ ██████╔╝██║ ██║███████╗ +# ██║╚██╔╝██║██╔══██║██║ ██╔══██╗██║ ██║╚════██║ +# ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║╚██████╔╝███████║ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ +# +# 'pause_macro' defines what macro to call on MMU error (must put printer in paused state) +# Other macros are detailed in 'mmu_sequence.cfg' +# Also see form_tip_macro in Tip Forming section and purge_macro in Purging section +# +pause_macro: PAUSE # What macro to call to pause the print +action_changed_macro: _MMU_ACTION_CHANGED # Called when action (printer.mmu.action) changes +print_state_changed_macro: _MMU_PRINT_STATE_CHANGED # Called when print state (printer.mmu.print_state) changes +mmu_event_macro: _MMU_EVENT # Called on useful MMU events +pre_unload_macro: _MMU_PRE_UNLOAD # Called before starting the unload +post_form_tip_macro: _MMU_POST_FORM_TIP # Called immediately after tip forming +post_unload_macro: _MMU_POST_UNLOAD # Called after unload completes +pre_load_macro: _MMU_PRE_LOAD # Called before starting the load +post_load_macro: _MMU_POST_LOAD # Called after the load is complete +unload_sequence_macro: _MMU_UNLOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_unload_sequence' +load_sequence_macro: _MMU_LOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_load_sequence' + + +# ADVANCED: See documentation for use of these ----------------------------------------------------------------------- +# ██████╗ ███████╗███████╗███████╗████████╗ ██████╗ ███████╗███████╗███████╗ +# ██╔══██╗██╔════╝██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██╔════╝ +# ██████╔╝█████╗ ███████╗█████╗ ██║ ██║ ██║█████╗ █████╗ ███████╗ +# ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║ ██║ ██║██╔══╝ ██╔══╝ ╚════██║ +# ██║ ██║███████╗███████║███████╗ ██║ ██████╔╝███████╗██║ ███████║ +# ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ +# +# These are the values that the various "RESET" commands will reset too rather than the built-in defaults. The lenght +# of the lists must match the number of gates on your MMU +# +# e.g. MMU_GATE_MAP RESET=1 - will use all the 'gate_XXX' values +# MMU_TTG_MAP RESET=1 - will use the 'tool_to_gate_map' +# MMU_ENDLESS_SPOOL_GROUPS RESET=1 - will use the 'endless_spool_groups' +# +# Gate: #0 #1 #2 #3 #4 #5 #6 #7 #8 +#gate_status: 1, 0, 1, 2, 2, -1, -1, 0, 1 +#gate_filament_name: one, two, three, four, five, six, seven, eight, nine +#gate_material: PLA, ABS, ABS, ABS+, PLA, PLA, PETG, TPU, ABS +#gate_color: red, black, yellow, green, blue, indigo, ffffff, grey, black +#gate_temperature: 210, 240, 235, 245, 210, 200, 215, 240, 240 +#gate_spool_id: 3, 2, 1, 4, 5, 6, 7, -1, 9 +#gate_speed_override: 100, 100, 100, 100, 100, 100, 100, 50, 100 +#endless_spool_groups: 0, 1, 2, 1, 0, 0, 3, 4, 1 +# +# Tool: T0 T1 T2 T3 T4 T5 T6 T7 T8 +#tool_to_gate_map: 0, 1, 2, 3, 4, 5, 6, 7, 8 diff --git a/config/base/mmu_parameters.cfg.vs b/config/base/mmu_parameters.cfg.vs new file mode 100644 index 000000000000..a295642daf49 --- /dev/null +++ b/config/base/mmu_parameters.cfg.vs @@ -0,0 +1,756 @@ +######################################################################################################################## +# Happy Hare MMU Software +# +# Template file for MMU's with Virtual Selector (Type-B designs like Box Turtle, Night Owl, Angry Beaver, ...) +# This file omits selector and selector-servo configuration and a few other options that don't make sense +# +# EDIT THIS FILE BASED ON YOUR SETUP +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Main configuration parameters for the klipper module +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# Notes: +# Macro configuration is specified separately in 'mmu_macro_vars.cfg'. +# Full details in https://github.com/moggieuk/Happy-Hare/tree/main/doc/configuration.md +# +[mmu] +happy_hare_version: {happy_hare_version} # Don't mess, used for upgrade detection + +# MMU Hardware Limits -------------------------------------------------------------------------------------------------- +# ██╗ ██╗███╗ ███╗██╗████████╗███████╗ +# ██║ ██║████╗ ████║██║╚══██╔══╝██╔════╝ +# ██║ ██║██╔████╔██║██║ ██║ ███████╗ +# ██║ ██║██║╚██╔╝██║██║ ██║ ╚════██║ +# ███████╗██║██║ ╚═╝ ██║██║ ██║ ███████║ +# ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ +# +# Define the physical limits of your MMU. These settings will be respected regardless of individual speed settings. +# +gear_max_velocity: 300 # Never to be exceeded gear velocity regardless of specific parameters +gear_max_accel: 1500 # Never to be exceeded gear acceleration regardless of specific parameters + + +# Logging -------------------------------------------------------------------------------------------------------------- +# ██╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ +# ██║ ██╔═══██╗██╔════╝ ██╔════╝ ██║████╗ ██║██╔════╝ +# ██║ ██║ ██║██║ ███╗██║ ███╗██║██╔██╗ ██║██║ ███╗ +# ██║ ██║ ██║██║ ██║██║ ██║██║██║╚██╗██║██║ ██║ +# ███████╗╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝ +# ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# log_level & logfile_level can be set to one of (0 = essential, 1 = info, 2 = debug, 3 = trace, 4 = stepper moves) +# Generally you can keep console logging to a minimal whilst still sending debug output to the mmu.log file +# Increasing the console log level is only really useful during initial setup to save having to constantly open the log file +# Note: that it is not recommended to keep logging at level greater that 2 (debug) if not debugging an issue because +# of the additional overhead +# +log_level: 1 +log_file_level: 2 # Can also be set to -1 to disable log file completely +log_statistics: 1 # 1 to log statistics on every toolchange (default), 0 to disable (but still recorded) +log_visual: 1 # 1 log visual representation of filament, 0 = disable +log_startup_status: 1 # Whether to log tool to gate status on startup, 1 = summary (default), 0 = disable +log_m117_messages: 1 # Whether send toolchange message via M117 to screen + + +# Movement speeds ------------------------------------------------------------------------------------------------------ +# ███████╗██████╗ ███████╗███████╗██████╗ ███████╗ +# ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝ +# ███████╗██████╔╝█████╗ █████╗ ██║ ██║███████╗ +# ╚════██║██╔═══╝ ██╔══╝ ██╔══╝ ██║ ██║╚════██║ +# ███████║██║ ███████╗███████╗██████╔╝███████║ +# ╚══════╝╚═╝ ╚══════╝╚══════╝╚═════╝ ╚══════╝ +# +# Long moves are faster than the small ones and used for the bulk of the bowden movement. You can set two fast load speeds +# depending on whether pulling from the spool or filament buffer (if fitted and not the first time load). This can be helpful +# in allowing faster loading from buffer and slower when pulling from the spool because of the additional friction (prevents +# loosing steps). Unloading speed can be tuning if you have a rewinder system that imposes additional limits. +# NOTE: Encoder cannot keep up much above 450mm/s so make sure 'bowden_apply_correction' is off at very high speeds! +# +gear_from_spool_speed: 80 # mm/s Speed when loading from the spool (for the first time if has_filament_buffer: 1) +gear_from_spool_accel: 100 # Acceleration when loading from spool +gear_from_buffer_speed: 150 # mm/s Speed when loading filament from buffer. Conservative is 100mm/s, Max around 400mm/s +gear_from_buffer_accel: 400 # Normal acceleration when loading filament +gear_unload_speed: 80 # mm/s Use (lower) speed when unloading filament (defaults to "from spool" speed) +gear_unload_accel: 100 # Acceleration when unloading filament (defaults to "from spool" accel) +# +gear_short_move_speed: 80 # mm/s Speed when making short moves (like incremental retracts with encoder) +gear_short_move_accel: 600 # Usually the same as gear_from_buffer_accel (for short movements) +gear_short_move_threshold: 70 # Move distance that controls application of 'short_move' speed/accel +gear_homing_speed: 50 # mm/s Speed of gear stepper only homing moves (e.g. homing to gate or extruder) + +# Speeds of extruder movement. The 'sync' speeds will be used when gear and extruder steppers are moving in sync +# +extruder_load_speed: 16 # mm/s speed of load move inside extruder from homing position to meltzone +extruder_unload_speed: 16 # mm/s speed of unload moves inside of extruder (very initial move from meltzone is 50% of this) +extruder_sync_load_speed: 18 # mm/s speed of synchronized extruder load moves +extruder_sync_unload_speed: 18 # mm/s speed of synchronized extruder unload moves +extruder_homing_speed: 18 # mm/s speed of extruder only homing moves (e.g. to toolhead sensor) + +# When Happy Hare calls out to a macro for user customization and for parking moves these settings are applied and the previous +# values automatically restored afterwards. This allows for deterministic movement speed regardless of the starting state. +# +macro_toolhead_max_accel: 0 # Default printer toolhead acceleration applied when macros are run. 0 = use printer max +macro_toolhead_min_cruise_ratio: 0.5 # Default printer cruise ratio applied when macros are run + + +# Gate loading/unloading ----------------------------------------------------------------------------------------------- +# ██████╗ █████╗ ████████╗███████╗ ██╗ ██████╗ █████╗ ██████╗ +# ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██║ ███╗███████║ ██║ █████╗ ██║ ██║ ██║███████║██║ ██║ +# ██║ ██║██╔══██║ ██║ ██╔══╝ ██║ ██║ ██║██╔══██║██║ ██║ +# ╚██████╔╝██║ ██║ ██║ ███████╗ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +# These settings control the loading and unloading filament at the gate which is the parking position inside the MMU. +# Typically this would be switch sensor but you can also use an encoder. Even with encoder the endstop can be a switch +# and the encoder used for move verifcation (see advanced 'gate_endstop_to_encoder' option). Note that the `encoder` +# method, due to the nature of its operation will overshoot a little. This is not a problem in practice because the +# overshoot will simply be compensated for in the subsequent move. A +ve parking distance moves towards the MMU, -ve +# moves back through the endstop towards the toolhead. If the MMU has multiple bowden tubes then it is possible to home +# at the extruder sensor and avoid long bowden moves! +# +# Possible gate_homing_endstop names: +# encoder - Detect filament position using movement of the encoder +# mmu_gate - Use gate endstop +# mmu_gear - Use individual per-gate endstop (type-B MMU's) +# extruder - Use extruder entry sensor (Only for some type-B designs, see [mmu_machine] require_bowden_move setting) +# +gate_homing_endstop: encoder # Name of gate endstop, "encoder" forces use of encoder for parking +gate_homing_max: 70 # Maximum move distance to home to the gate (or actual move distance for encoder parking) +gate_preload_homing_max: 70 # Maximum homing distance to the mmu_gear endstop (if MMU is fitted with one) +gate_preload_parking_distance: -10 # Parking position relative to mmu_gear endstop (-ve value means move forward) +gate_unload_buffer: 50 # Amount to reduce the fast unload so that filament doesn't overshoot when parking +gate_parking_distance: 23 # Parking position in the gate (distance back from homing point, -ve value means move forward) +gate_endstop_to_encoder: 10 # Distance between gate endstop and encoder (IF both fitted. +ve if encoder after endstop) +gate_autoload: 1 # If pre-gate sensor fitted this controls the automatic loading of the gate +gate_final_eject_distance: 0 # Distance to eject filament on MMU_EJECT (Ignored by MMU_UNLOAD) + + +# Bowden tube loading/unloading ---------------------------------------------------------------------------------------- +# ██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ +# ██╔══██╗██╔═══██╗██║ ██║██╔══██╗██╔════╝████╗ ██║ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██████╔╝██║ ██║██║ █╗ ██║██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║███████║██║ ██║ +# ██╔══██╗██║ ██║██║███╗██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██║██║ ██║ +# ██████╔╝╚██████╔╝╚███╔███╔╝██████╔╝███████╗██║ ╚████║ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +bowden_homing_max: 2000 # Maximum attempted bowden move (for calibration). Should be larger than your actual bowden! + +# If you MMU is equiped with an encoder the following options are available: +# +# In addition to different bowden loading speeds for buffer and non-buffered filament it is possible to detect missed +# steps caused by "jerking" on a heavy spool. If bowden correction is enabled Happy Hare will "believe" the encoder +# reading and make correction moves to bring the filament to within the 'bowden_allowable_load_delta' of the end of +# bowden position (this does require a reliable encoder and is not recommended for very high speed loading >350mm/s) +# +bowden_apply_correction: 0 # 1 to enable, 0 disabled +bowden_allowable_load_delta: 20.0 # How close in mm the correction moves will attempt to get to target +# +# This saftey check uses the encoder to verify the filament is free of extruder before the fast bowden movement to +# reduce possibility of grinding filament. If enabled the trigger can be tuned by setting the "error tolerance" which +# represents the fraction of allowable mismatch between actual movement and that seen by encoder. Setting to 50% tolerance +# usually works well. Increasing will make test more tolerant. Value of 100% essentially disables error detection +# +bowden_pre_unload_test: 1 # 1 to check for bowden movement before full pull (slower), 0 don't check (faster) +bowden_pre_unload_error_tolerance: 50 # ADVANCED: tune pre_unload_test + + +# Extruder homing ----------------------------------------------------------------------------------------------------- +# ███████╗██╗ ██╗████████╗ ██╗ ██╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ +# ██╔════╝╚██╗██╔╝╚══██╔══╝ ██║ ██║██╔═══██╗████╗ ████║██║████╗ ██║██╔════╝ +# █████╗ ╚███╔╝ ██║ ███████║██║ ██║██╔████╔██║██║██╔██╗ ██║██║ ███╗ +# ██╔══╝ ██╔██╗ ██║ ██╔══██║██║ ██║██║╚██╔╝██║██║██║╚██╗██║██║ ██║ +# ███████╗██╔╝ ██╗ ██║██╗ ██║ ██║╚██████╔╝██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ +# ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# Happy Hare needs a reference "homing point" close to the extruder from which to accurately complete the loading of +# the toolhead. This homing operation takes place after the fast bowden load and it is anticipated that that load +# operation will leave the filament just shy of the homing point. If using a toolhead sensor this initial extruder +# homing is unnecessary (but can be forced) because the homing will occur inside the extruder for the optimum in accuracy. +# You still should set this homing method because it is also used for the determination and calibration of bowden length. +# +# In addition to an entry sensor "extruder" it is possible for Happy Hare to "feel" for the extruder gear entry +# by colliding with it. This can be done with encoder based collision detection, the compression of the sync-feedback +# (aka buffer) sensor or using "touch" (stallguard) on the gear stepper. Note that encoder collision detection is not +# completely deterministic and you will have to find the sweetspot for your setup by adjusting the TMC current reduction. +# Note that reduced current during collision detection can also prevent unecessary filament griding. +# +# Possible extruder_homing_endtop names: +# collision - Detect the collision with the extruder gear by monitoring encoder movement (Requires encoder) +# Fast bowden load will move to the extruder gears +# mmu_gear_touch - Use touch detection when the gear stepper hits the extruder (Requires stallguard) +# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home +# extruder - If you have a "filament entry" endstop configured (Requires 'extruder' endstop) +# Fast bowden load will move to extruder_homing_buffer distance before sensor, then home +# filament_compression - If you have a "sync-feedback" sensor with compression switch configured +# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home +# none - Don't attempt to home. Only possibiliy if lacking all sensor options +# Fast bowden load will move to the extruder gears. Option is fine if using toolhead sensor +# Note: The homing_endstop will be ignored ("none") if a toolhead sensor is available unless "extruder_force_homing: 1" +# +extruder_homing_max: 80 # Maximum distance to advance in order to attempt to home the extruder +extruder_homing_endstop: collision # Filament homing method/endstop name (fallback if toolhead sensor not available) +extruder_homing_buffer: 25 # Amount to reduce the fast bowden load so filament doesn't overshoot the extruder homing point +extruder_collision_homing_current: 30 # % gear_stepper current (10%-100%) to use when homing to extruder homing (100 to disable) + +# If you have a toolhead sensor it will always be used as a homing point making the homing outside of the extruder +# potentially unnecessary. However you can still force this initial homing step by setting this option in which case +# the filament will home to the extruder and then home to the toolhead sensor in two steps +# +extruder_force_homing: 0 + + +# Toolhead loading and unloading -------------------------------------------------------------------------------------- +# ████████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗███████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ +# ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗ +# ██║ ██║ ██║██║ ██║██║ ███████║█████╗ ███████║██║ ██║ ██║ ██║ ██║███████║██║ ██║ +# ██║ ██║ ██║██║ ██║██║ ██╔══██║██╔══╝ ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║ ██║ +# ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║███████╗██║ ██║██████╔╝ ███████╗╚██████╔╝██║ ██║██████╔╝ +# ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ +# +# It is possible to define highly customized loading and unloading sequences, however, unless you have a specialized +# setup it is probably easier to opt for the built-in toolhead loading and unloading sequence which already offers a +# high degree of customization. If you need even more control then edit the _MMU_LOAD_SEQUENCE and _MMU_UNLOAD_SEQUENCE +# macros in mmu_sequence.cfg - but be careful! +# +# An MMU must have a known point at the end of the bowden from which it can precisely load the extruder. Generally this +# will either be the extruder entrance (which is controlled with settings above) or by homing to toolhead sensor. If +# you have toolhead sensor it is past the extruder gear and the driver needs to know the max distance (from end of +# bowden move) to attempt homing +# +toolhead_homing_max: 40 # Maximum distance to advance in order to attempt to home to defined homing endstop + +# IMPORTANT: These next three settings are based on the physical dimensions of your toolhead +# Once a homing position is determined, Happy Hare needs to know the final move distance to the nozzle. There is only +# one correct value for your setup - use 'toolhead_ooze_reduction' (which corresponds to the residual filament left in +# your nozzle) to control excessive oozing on load. See doc for table of proposed values for common configurations. +# +# NOTE: If you have a toolhead sensor you can automate the calculation of these parameters! Read about the +# `MMU_CALIBRATE_TOOLHEAD` command (https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing#---calibrating-toolhead) +# +toolhead_extruder_to_nozzle: 72 # Distance from extruder gears (entrance) to nozzle +toolhead_sensor_to_nozzle: 62 # Distance from toolhead sensor to nozzle (ignored if not fitted) +toolhead_entry_to_extruder: 8 # Distance from extruder "entry" sensor to extruder gears (ignored if not fitted) + +# This setting represents how much residual filament is left behind in the nozzle when filament is removed, it is thus +# used to reduce the extruder loading length and prevent excessive blobbing but also in the calculation of purge volume. +# Note that this value can also be measured with the `MMU_CALIBRATE_TOOLHEAD` procedure +# +toolhead_residual_filament: 0 # Reduction in extruder loading length because of residual filament left behind + +# TUNING: Finally, this is the last resort tuning value to fix blobbing. It is expected that this value is NEAR ZERO as +# it represents a further reduction in extruder load length to fix blobbing. If using a wipetower and you experience blobs +# on it, increase this value (reduce the quantity of filament loaded). If you experience gaps, decrease this value. If gaps +# and already at 0 then perhaps the 'toolhead_extruder_to_nozzle' or 'toolhead_residual_filament' settings are incorrect. +# Similarly a value >+5mm also suggests the four settings above are not correct. Also see 'retract' setting in +# 'mmu_macro_vars.cfg' for final in-print ooze tuning. +# +toolhead_ooze_reduction: 0 # Reduction in extruder loading length to prevent ooze (represents filament remaining) + +# Distance added to the extruder unload movement to ensure filament is free of extruder. This adds some degree of tolerance +# to slightly incorrect configuration or extruder slippage. However don't use as an excuse for incorrect toolhead settings +# +toolhead_unload_safety_margin: 10 # Extra movement safety margin (default: 10mm) + +# If not synchronizing gear and extruder and you experience a "false" clog detection immediately after the tool change +# it might be because of a long bowden and/or large internal diameter that causes slack in the filament. This optional +# move will tighten the filament after a load by % of current clog detection length. Gear stepper will run at 50% current +# +toolhead_post_load_tighten: 60 # % of clog detection length, 0 to disable. Ignored if 'sync_to_extruder: 1' + +# If synchronizing gear and extruder and you have a sync-feedback "buffer" this setting determines whether to use it +# to create neutral tension after loading +toolhead_post_load_tension_adjust: 1 # 1 to enable (recommended), 0 to disable + +# If sync-feedback compression sensor is available this test will ensure the filament passes the extruder entry by checking +# for neutral tension when moving filament with just the extruder. Recommended with sprung loaded sync-feedback buffers. +# This is ignored if toolhead sensor is available. +toolhead_entry_tension_test: 1 # 1 to enable (recommended), 0 to disable + +# ADVANCED: Controls the detection of successful extruder load/unload movement and represents the fraction of allowable +# mismatch between actual movement and that seen by encoder. Setting to 100% tolerance effectively turns off checking. +# Some designs of extruder have a short move distance that may not be picked up by encoder and cause false errors. This +# allows masking of those errors. However the error often indicates that your extruder load speed is too high or the +# friction is too high on the filament and in that case masking the error is not a good idea. Try reducing friction +# and lowering speed first! +# +toolhead_move_error_tolerance: 60 + + +# Tip forming --------------------------------------------------------------------------------------------------------- +# ████████╗██╗██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ +# ╚══██╔══╝██║██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ +# ██║ ██║██████╔╝ █████╗ ██║ ██║██████╔╝██╔████╔██║██║██╔██╗ ██║██║ ███╗ +# ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██║ ██║ +# ██║ ██║██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# Tip forming responsibility can be split between slicer (in-print) and standalone macro (not in-print) or forced to always +# be done by Happy Hare's standalone macro. Since you always need the option to form tips without the slicer so it is +# generally easier to completely turn off the slicer, force "standalone" tip forming and tune only in Happy Hare. +# +# When Happy Hare is asked to form a tip it will run the referenced macro. Two are reference examples are provided but +# you can implement your own: +# _MMU_FORM_TIP .. default tip forming similar to popular slicers like Superslicer and Prusaslicer +# _MMU_CUT_TIP .. for Filametrix (originally ERCFv2) or similar style toolhead filament cutting system +# +# NOTE: For MMU located cutting like the optional EREC cutter you should set still this to _MMU_FORM_TIP to build a decent +# tip prior to extraction and cutting after the unload. +# +# Often it is useful to increase the extruder current for the rapid movement to ensure high torque and no skipped steps +# +# If opting for slicer tip forming you MUST configure where the slicer leaves the filament in the extruder since +# there is no way to determine this. This can be ignored if all tip forming is performed by Happy Hare +# +force_form_tip_standalone: 1 # 0 = Slicer in print else standalone, 1 = Always standalone tip forming (TURN SLICER OFF!) +form_tip_macro: _MMU_FORM_TIP # Name of macro to call to perform the tip forming (or cutting) operation +extruder_form_tip_current: 100 # % of extruder current (100%-150%) to use when forming tip (100 to disable) +slicer_tip_park_pos: 0 # This specifies the position of filament in extruder after slicer completes tip forming + + +# Purging ------------------------------------------------------------------------------------------------------------- +# ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ +# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██║████╗ ██║██╔════╝ +# ██████╔╝██║ ██║██████╔╝██║ ███╗██║██╔██╗ ██║██║ ███╗ +# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║ +# ██║ ╚██████╔╝██║ ██║╚██████╔╝██║██║ ╚████║╚██████╔╝ +# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ +# +# After a toolchange it is necessary to purge the old filament. Similar to tip forming this can be done by the slicer and/or +# by Happy Hare using an extension like Blobifer. If a purge_macro is defined it will be called when not printing or whenever +# the slicer isn't going to purge (like initial tool load). You can force it to always be called in a print by setting +# force_purge_standalone, but remember to turn off the slicer wipetower +# +# The default is for no (empty) macro so purging will not be done out of a print and thus wipetower. Two options are shipped with +# Happy Hare but you can also build your own custom one: +# _MMU_PURGE .. default purging that just dumps the desired amount of filament (setup correct parking before enabling this!) +# BLOBIFER .. for excellent Blobifer addon (https://github.com/Dendrowen/Blobifier) +# +# Often it is useful to increase the extruder current for the often rapid puring movement to ensure high torque and no skipped steps +# +force_purge_standalone: 0 # 0 = Slicer wipetower in print else standalone, 1 = Always standalone purging (TURN WIPETOWER OFF!) +purge_macro: _MMU_PURGE # Name of macro to call to perform the standalone purging operation. E.g. BLOBIFIER, _MMU_PURGE +extruder_purge_current: 100 # % of extruder current (100%-150%) to use when purging (100 to disable) + + +# Synchronized gear/extruder movement ---------------------------------------------------------------------------------- +# ███╗ ███╗ ██████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗███╗ ██╗ ██████╗ +# ████╗ ████║██╔═══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██╔════╝╚██╗ ██╔╝████╗ ██║██╔════╝ +# ██╔████╔██║██║ ██║ ██║ ██║ ██║██████╔╝ ███████╗ ╚████╔╝ ██╔██╗ ██║██║ +# ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║██╔══██╗ ╚════██║ ╚██╔╝ ██║╚██╗██║██║ +# ██║ ╚═╝ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║ ███████║ ██║ ██║ ╚████║╚██████╗ +# ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ +# +# This controls whether the extruder and gear steppers are synchronized during printing operations +# If you normally run with maxed out gear stepper current consider reducing it with 'sync_gear_current' +# If equipped with TMC drivers the current of the gear and extruder motors can be controlled to optimize performance. +# This can be useful to control gear stepper temperature when printing with synchronized motor +# +sync_gear_current: 70 # % of gear_stepper current (10%-100%) to use when syncing with extruder during print + +# Optionally it is possible to leverage feedback from a "compression/expansion" sensor (aka "buffer") in the bowden +# path from MMU to extruder to ensure that the two motors are kept in sync as viewed by the filament (the signal feedback +# state can be binary supplied by one or two switches: -1 (expanded) and 1 (compressed) of proportional value between +# -1.0 and 1.0. +# +# If only "one half" of the sync-feedback is available (either compression-only or tension-only) then the rotation +# distance is always shifted based on the high/low multipliers, however if both tension and compression are available +# then the rotation distance will autotune to correct setting (recommend you also enable 'autotune_rotation_distance: 1' +# Note that proportional feedback sensors are continuously dynamic +# +# Possible buffer setups, forth option for type where neutral is when both sensors are active: +# +# <------maxrange------> <------maxrange------> <------maxrange------> <------maxrange------> +# <--range---> <----range-----> <----range-----> <> range=0 +# |====================| |====================| |====================| |====================| +# ^ ^ ^ ^ ^^ +# compression tension compression-only tension-only +# +sync_feedback_enabled: 0 # Turn off even if sensor is installed and active +sync_feedback_buffer_range: 6 # Travel in "buffer" between compression/tension or one sensor and end (see above) +sync_feedback_buffer_maxrange: 12 # Absolute maximum end-to-end travel (mm) provided by buffer (see above) +sync_feedback_speed_multiplier: 5 # % "twolevel" gear speed delta to keep filament neutral in buffer (recommend 5%) +sync_feedback_boost_multiplier: 3 # % "twolevel" extra gear speed boost for finding initial neutral position (recommend 3%) +sync_feedback_extrude_threshold: 5 # Extruder movement (mm) for updates (keep small but set > retract distance) + +# If defined this forces debugging to a telemetry log file "sync_.jsonl". This is great if trying to tune clog/tangle +# detection or for getting help on the Happy Hare forum. To plot graph of sync-feedback operation, run: +# ~/Happy-Hare/utils/plot_sync_feedback.sh +# +sync_feedback_debug_log: 0 # 0 = disable (normal opertion), 1 = enable telemetry log (for debugging) + + +# ESpooler control ----------------------------------------------------------------------------------------------------- +# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ +# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ +# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ +# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ +# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ +# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ +# +# If your MMU has a dc motor (often N20) controlled respooler/assist then how it operates can be controlled with these +# settings. Typically the espooler will be controlled with PWM signal. This will be at the maximum at speeds equal or +# above 'espooler.max_stepper_speed'. The PWM signal will scale downwards towards 0 for slower speeds. The falloff being +# controlled by the 'espooler_speed_exponent' setting according to this formula and allows for non-linear characteristics +# the DC motor (0.5 is a good starting value). +# +# espooler_pwm = (stepper_speed / espooler_max_stepper_speed) ^ {espooler_speed_exponent} +# +# Regardless of h/w configuration you can enable/disable actions with the 'espooler_operations' list. E.g. remove 'play' to +# turn off operation while printing. Options are: +# +# rewind - when filament is being unloaded under MMU control (aka respool) +# assist - when filament is being loaded under MMU control (% of "rewind" speed but with minimum of "print" power) +# print - while printing. Generally set 'espooler_printing_power' to a low percentage just to allow motor to be turned +# freely or set to 0 to enable/allow "burst" assist movements +# +# If using a digitally controlled espooler motor (not PWM) then you should turn off the "print" mode and set +# 'espooler_min_stepper_speed' to prevent "over movement" +# +espooler_min_distance: 30 # Individual stepper movements less than this distance will not active espooler +espooler_max_stepper_speed: 300 # Gear stepper speed at which espooler will be at maximum power +espooler_min_stepper_speed: 0 # Gear stepper speed at which espooler will become inactive (useful for non PWM control) +espooler_speed_exponent: 0.5 # Controls non-linear espooler power relative to stepper speed (see notes) +espooler_assist_reduced_speed: 50 # Control the % of the rewind speed that is applied to assisting load (want rewind to be faster) +espooler_printing_power: 0 # If >0, fixes the % of PWM power while printing. 0=allows burst movement +espooler_operations: rewind, assist, print # List of operational modes (allows disabling even if h/w is configured) +# +# The following burst configuration is used to control the small rotation in the ASSIST direction optionally used +# when in 'print' operation is enabled, 'espooler_printing_power: 0' and is triggered (tension switch or extruder movement). +# It can also be used to loosen filament with 'MMU_ESPOOLER COMMAND=assist BURST=1' +# +espooler_assist_extruder_move_length: 100 # Distance (mm) extruder needs to move between each assist burst +espooler_assist_burst_power: 100 # The % power of the burst move +espooler_assist_burst_duration: 0.4 # The duration of the burst move is seconds +espooler_assist_burst_trigger: 0 # If trigger assist switch is fitted 0=disable, 1=enable +espooler_assist_burst_trigger_max: 3 # If trigger assist switch is fitted this limits the max number of back-to-back advances +# +# The following burst configuration is used to control the small rotation in the REWIND direction optionally used +# when running running the filament drying cycle. The goal is to rotate the spool 60-90 degrees. It can also be +# used to tighten the filament with 'MMU_ESPOOLER COMMAND=rewind BURST=1' +# +espooler_rewind_burst_power: 100 # The % power of the rewind burst move +espooler_rewind_burst_duration: 0.4 # The duration of the rewind burst move is seconds + + +# Heater / Environment Management ------------------------------------------------------------------------------------ +# ██╗ ██╗███████╗ █████╗ ████████╗███████╗██████╗ +# ██║ ██║██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ +# ███████║█████╗ ███████║ ██║ █████╗ ██████╔╝ +# ██╔══██║██╔══╝ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ +# ██║ ██║███████╗██║ ██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +heater_max_temp: 70 # Absolute max heater setting to protect the MMU enclosure construction (adjust to match material) +heater_default_dry_temp: 45 # Default drying temperature if filament type is not matched in drying_data +heater_default_dry_time: 300 # Default drying cycle time in minutes +heater_default_humidity: 25 # Default humidity % goal. Drying will terminate if this value is reached +heater_vent_macro: _MMU_VENT # Name of macro to periodicaly call during drying cycle +heater_vent_interval: 0 # Interval in minutes to call heater_vent_macro during drying cycle, 0=disable venting +heater_rotate_interval: 5 # Interval in minutes to rotate filament (requires eSpooler and filament end attached to spool) + +# Drying data for MMU_HEATER DRY=1 command in form (material type is case insensitive): +# 'filament_type': (temp, drying_time_mins) +# +# (Careful with formatting of this line - reformatting will break upgrade logic) +# +drying_data: { 'pla': (45, 300), 'pla+': (55, 300), 'petg': (60, 300), 'tpu': (55, 300), 'abs': (70, 300), 'abs+': (75, 300), 'asa': (65, 300), 'nylon': (75, 600), 'pc': (75, 600), 'pva': (75, 600), 'hips': (75, 600) } + + +# FlowGuard Clog and Tangle Detection -------------------------------------------------------------------------------- +# ███████╗██╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ +# ██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗ +# █████╗ ██║ ██║ ██║██║ █╗ ██║██║ ███╗██║ ██║███████║██████╔╝██║ ██║ +# ██╔══╝ ██║ ██║ ██║██║███╗██║██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║ +# ██║ ███████╗╚██████╔╝╚███╔███╔╝╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝ +# ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ +# +# Options are available to automatically detects extruder clogs and MMU tangles. Each option works independently and +# can be combined. Flowguard can even discern the difference between an extruder clog and a spool tangle! +# +# Flowguard: This intelligently measures filament tension (only available if sync-feedback buffer is fitted) +# +# Encoder detection: This monitors encoder movement and compares to extruder (only available if encoder is fitted) +# +flowguard_enabled: 1 # 0 = Flowguard protection disabled, 1 = Enabled + +# The flowguard_max_relief is the amount of relief movement (effective mm change in filament length between MMU and extruder) +# that Happy Hare will wait until triggering a clog or runout. A smaller value is more sensitive to triggering. Since the +# relief movement is hightly dependent on filament "spring" in the bowden tube, filament friction, and +# 'sync_feedback_buffer_range', it is generally good to start high and then decrease if a more sensitive trigger is desired. +# Analog proportional (type P) sensors can generally have a much lower value. Increase if you have false triggers. +flowguard_max_relief: 40 + +# Encoder runout/clog/tangle detection watches for movement over either a static or automatically adjusted distance - if +# no encoder movement is seen when the extruder moves this distance runout/ clog/tangle event will be generated. Allowing +# the distance to be adjusted automatically (mode=2) will generally allow for a quicker trigger but use a static length +# (mode=1, set encoder_max_motion) if you get false triggers (see flowguard guide on wiki for more details). +# Note that this feature cannot disinguish between clog or tangle. +flowguard_encoder_mode: 2 # 0 = Disable, 1 = Static length clog detection, 2 = Automatic length clog detection + +# The encoder_max_motion is the absolute max permitted extruder movement without the encoder seeing movement when using +# status mode (mode=1). Smaller values are more sensitive but beware of going too small - slack and friction in the +# bowden may cause gaps in encoder movement. Increase if you have false triggers. +# Note that this value is overriden by any calibrated value stored in 'mmu_vars.cfg' if in automatic mode (mode=2). +flowguard_encoder_max_motion: 20 + + +# Filament Management Options ---------------------------------------------------------------------------------------- +# ███████╗██╗██╗ ███╗ ███╗ ██████╗ ███╗ ███╗████████╗ +# ██╔════╝██║██║ ████╗ ████║██╔════╝ ████╗ ████║╚══██╔══╝ +# █████╗ ██║██║ ██╔████╔██║██║ ███╗██╔████╔██║ ██║ +# ██╔══╝ ██║██║ ██║╚██╔╝██║██║ ██║██║╚██╔╝██║ ██║ +# ██║ ██║███████╗██╗ ██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ +# +# - EndlessSpool feature allows detection of runout on one spool and the automatic mapping of tool to an alternative +# gate (spool). Set to '1', this feature requires clog detection or gate sensor or pre-gate sensors. EndlessSpool +# functionality can optionally be extended to attempt to load an empty gate with 'endless_spool_on_load'. On some MMU +# designs (with linear selector) it can also be configured to eject filament remains to a designated gate rather than +# defaulting to current gate. A custom gate will disable pre-gate runout detection for EndlessSpool because filament +# end must completely pass through the gate for selector to move +# +endless_spool_enabled: 1 # 0 = disable, 1 = enable endless spool +endless_spool_on_load: 0 # 0 = don't apply endless spool on load, 1 = run endless spool if gate is empty +endless_spool_eject_gate: -1 # Which gate to eject the filament remains. -1 = current gate +#endless_spool_groups: # Default EndlessSpool groups (see later in file) +# +# Spoolman support requires you to correctly enable spoolman with moonraker first. If enabled, the gate SpoolId will +# be used to load filament details and color from the spoolman database and Happy Hare will activate/deactivate +# spools as they are used. The enabled variation allows for either the local map or the spoolman map to be the +# source of truth as well as just fetching filament attributes. See this table for explanation: +# +# | Activate/ | Fetch filament attributes | Filament gate | Filament gate | +# spoolman_support | Deactivate | attributes from spoolman | assignment shown | assignment pulled | +# | spool? | based on spool_id? | in spoolman db? | from spoolman db? | +# -----------------+------------+---------------------------+------------------+-------------------+ +# off | no | no | no | no | +# readonly | yes | yes | no | no | +# push | yes | yes | yes | no | +# pull | yes | yes | yes | yes | +# +spoolman_support: off # off = disabled, readonly = enabled, push = local gate map, pull = remote gate map +pending_spool_id_timeout: 20 # Seconds after which this pending spool_id (set with rfid) is voided +# +# Mainsail/Fluid UI can visualize the color of filaments next to the extruder/tool chooser. The color is dynamic and +# can be customized to your choice: +# +# slicer - Color from slicer tool map (what the slicer expects) +# allgates - Color from all the tools in the gate map after running through the TTG map +# gatemap - As per gatemap but hide empty tools +# off - Turns off support +# +# Note: Happy Hare will also add the 'spool_id' variable to the Tx macro if spoolman is enabled +# +t_macro_color: slicer # 'slicer' = default | 'allgates' = mmu | 'gatemap' = mmu without empty gates | 'off' + + +# Print Statistics --------------------------------------------------------------------------------------------------- +# ███████╗████████╗ █████╗ ████████╗███████╗ +# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ +# ███████╗ ██║ ███████║ ██║ ███████╗ +# ╚════██║ ██║ ██╔══██║ ██║ ╚════██║ +# ███████║ ██║ ██║ ██║ ██║ ███████║ +# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ +# +# These parameters determine how print statistic data is shown in the console. This table can show a lot of data, +# probably more than you'd want to see. Below you can enable/disable options to your needs. +# +# +-----------+---------------------+----------------------+----------+ +# | 114(46) | unloading | loading | complete | +# | swaps | pre | - | post | pre | - | post | swap | +# +-----------+------+-------+------+------+-------+-------+----------+ +# | all time | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | +# | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | +# | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | +# | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | +# | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | +# +-----------+------+-------+------+------+-------+-------+----------+ +# Note: Only formats correctly on Python3 +# +# Comma separated list of desired columns +# Options: pre_unload, form_tip, unload, post_unload, pre_load, load, purge, post_load, total +console_stat_columns: unload, load, post_load, total + +# Comma separated list of rows. The order determines the order in which they're shown. +# Options: total, total_average, job, job_average, last +console_stat_rows: total, total_average, job, job_average, last + +# How you'd want to see the state of the gates and how they're performing +# string - poor, good, perfect, etc.. +# percentage - rate of success +# emoticon - fun sad to happy faces (python3 only) +console_gate_stat: emoticon + +# Always display the full statistics table +console_always_output_full: 1 # 1 = Show full table, 0 = Only show totals out of print + + +# Calibration and autotune ------------------------------------------------------------------------------------------- +# ██████╗ █████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ +# ██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ +# ██║ ███████║██║ ██║██████╔╝██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ +# ██║ ██╔══██║██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ +# ╚██████╗██║ ██║███████╗██║██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ +# ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ +# +# These are auto calibration/tuning settings that can be used to ease initial setup and/or to tune calibration over +# time based on measured telemetry. Whether these auto-tuning features are available depends on MMU design and +# configured sensors (explained below). The setting will be ignored if the required sensors are not available but if +# they can operate they will suppress the normal calibration warnings (MMU_STATUS can still be used to view them). +# Note that these are initially set by the installer to recommended values +# +# autocal_bowden_length - the calibrated bowden length will be established on first load. It can also be set +# manually or reset with MMU_CALIBRATE_BOWDEN. Best results require the use of +# sync-feedback-compression or extruder sensor but gear-touch or encoder will also work. +# 'extruder_homing_endstop' cannot be 'none' +# autotune_bowden_length - Once calibrated this setting will tune the bowden distance over time. Works best with +# toolhead sensor +# skip_cal_rotation_distance - This will rely on installed default value (although it can still be calibrated). Usually +# a good choice if autotune is enabled +# autotune_rotation_distance - Requires sync-feedback sensor (aka "buffer") or calibrated encoder. If set then either the +# "autotuner" (sync-feedback buffer) or encoder telemetry will be used to adjust the +# persisted gear rotation distance. +# skip_cal_encoder - Will rely on installed default value (although it can still be calibrates). +# Not recommended but allows for easier initial setup especially when 'autotune_encoder' +# is enabled. +# autotune_encoder - NOT IMPLEMENTED YET. Soon! +# +autocal_bowden_length: 1 # Automated bowden length calibration. 1=automatic, 0=manual/off +autotune_bowden_length: 1 # Automated bowden length tuning. 1=on, 0=off +skip_cal_rotation_distance: 0 # Skip rotation distance calibration (MMU_CALIBRATE_GEAR), 1=skip, 0=require +autotune_rotation_distance: 0 # Automated gate calibration/tuning. 1=automatic, 0=manual/off +skip_cal_encoder: 0 # Skip encoder calibration (MMU_CALIBRATE_ENCODER), 1=skip, 0=require +autotune_encoder: 0 # Automated encoder tuning. 1=automatic, 0=manual/off + + +# Miscellaneous, but you should review ------------------------------------------------------------------------------- +# ███╗ ███╗██╗███████╗ ██████╗ +# ████╗ ████║██║██╔════╝██╔════╝ +# ██╔████╔██║██║███████╗██║ +# ██║╚██╔╝██║██║╚════██║██║ +# ██║ ╚═╝ ██║██║███████║╚██████╗ +# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ +# +# Important you verify these work for you setup/workflow. Temperature and timeouts +# +timeout_pause: 72000 # Idle time out (printer shuts down) in seconds used when in MMU pause state +disable_heater: 600 # Delay in seconds after which the hotend heater is disabled in the MMU_PAUSE state +default_extruder_temp: 200 # Default temperature for performing swaps and forming tips when not in print (overridden by gate map) +extruder_temp_variance: 2 # When waiting for extruder temperature this is the +/- permissible variance in degrees (>= 1) +# +# Other workflow options +# +startup_home_if_unloaded: 0 # 1 = force mmu homing on startup if unloaded, 0 = do nothing +startup_reset_ttg_map: 0 # 1 = reset TTG map on startup, 0 = do nothing +show_error_dialog: 1 # 1 = show pop-up dialog in addition to console message, 0 = show error in console +preload_attempts: 5 # How many "grabbing" attempts are made to pick up the filament with preload feature +strict_filament_recovery: 0 # If enabled with MMU with toolhead sensor, this will cause filament position recovery to + # perform extra moves to look for filament trapped in the space after extruder but before sensor +filament_recovery_on_pause: 1 # 1 = Run a quick check to determine current filament position on pause/error, 0 = disable +retry_tool_change_on_error: 0 # Whether to automatically retry a failed tool change. If enabled Happy Hare will perform + # the equivalent of 'MMU_RECOVER' + 'Tx' commands which usually is all that is necessary + # to recover. Note that enabling this can mask problems with your MMU +bypass_autoload: 1 # If extruder sensor fitted this controls the automatic loading of extruder for bypass operation +has_filament_buffer: 1 # Whether the MMU has a filament buffer. Set to 0 if using Filamentalist or DC eSpooler, etc +# +# Advanced options. Don't mess unless you fully understand. Read documentation. +# +encoder_move_validation: 1 # ADVANCED: 1 = Normally Encoder validates move distances are within given tolerance + # 0 = Validation is disabled (eliminates slight pause between moves but less safe) +print_start_detection: 1 # ADVANCED: Enabled for Happy Hare to automatically detect start and end of print and call + # ADVANCED: MMU_PRINT_START and MMU_PRINT_END automatically. Harmless to leave enabled but can disable + # if you think it is causing problems and known START/END is covered in your macros +extruder: extruder # ADVANCED: Name of the toolhead extruder that MMU is using +gcode_load_sequence: 0 # VERY ADVANCED: Gcode loading sequence 1=enabled, 0=internal logic (default) +gcode_unload_sequence: 0 # VERY ADVANCED: Gcode unloading sequence, 1=enabled, 0=internal logic (default) + + +# ADVANCED: Klipper tuning ------------------------------------------------------------------------------------------- +# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ +# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ +# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ +# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ +# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ +# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +# +# Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing +# operations especially so with CANbus connected MCUs. Happy Hare uses many homing moves for reliable extruder loading +# and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error +# +update_trsync: 0 # 1 = Increase TRSYNC_TIMEOUT, 0 = Leave the klipper default +# +# Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms timeout will kill +# the print. Since it seems to occur only on homing moves they can be safely retried to workaround. This has been +# working well in practice +canbus_comms_retries: 3 # Number of retries. Recommend the default of 3. +# +# Older neopixels have very finicky timing and can generate lots of "Unable to obtain 'neopixel_result' response" +# errors in klippy.log. An often cited workaround is to increase BIT_MAX_TIME in neopixel.py. This option does that +# automatically for you to save dirtying klipper +update_bit_max_time: 1 # 1 = Increase BIT_MAX_TIME, 0 = Leave the klipper default +# +# BTT ViViD used a AHT30 sensor. If you are using an older klipper you may not have this sensor available. If so, use +# AHT10 and set this to 1 to convert AHT30 commands to AHT10 +update_aht10_commands: 0 # 1 = Config AHT10 for BTT ViViD heater sensor on older klipper, 0 = Leave the klipper default + + +# ADVANCED: MMU macro overrides --- ONLY SET IF YOU'RE COMFORTABLE WITH KLIPPER MACROS ------------------------------- +# ███╗ ███╗ █████╗ ██████╗██████╗ ██████╗ ███████╗ +# ████╗ ████║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔════╝ +# ██╔████╔██║███████║██║ ██████╔╝██║ ██║███████╗ +# ██║╚██╔╝██║██╔══██║██║ ██╔══██╗██║ ██║╚════██║ +# ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║╚██████╔╝███████║ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ +# +# 'pause_macro' defines what macro to call on MMU error (must put printer in paused state) +# Other macros are detailed in 'mmu_sequence.cfg' +# Also see form_tip_macro in Tip Forming section and purge_macro in Purging section +# +pause_macro: PAUSE # What macro to call to pause the print +action_changed_macro: _MMU_ACTION_CHANGED # Called when action (printer.mmu.action) changes +print_state_changed_macro: _MMU_PRINT_STATE_CHANGED # Called when print state (printer.mmu.print_state) changes +mmu_event_macro: _MMU_EVENT # Called on useful MMU events +pre_unload_macro: _MMU_PRE_UNLOAD # Called before starting the unload +post_form_tip_macro: _MMU_POST_FORM_TIP # Called immediately after tip forming +post_unload_macro: _MMU_POST_UNLOAD # Called after unload completes +pre_load_macro: _MMU_PRE_LOAD # Called before starting the load +post_load_macro: _MMU_POST_LOAD # Called after the load is complete +unload_sequence_macro: _MMU_UNLOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_unload_sequence' +load_sequence_macro: _MMU_LOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_load_sequence' + + +# ADVANCED: See documentation for use of these ----------------------------------------------------------------------- +# ██████╗ ███████╗███████╗███████╗████████╗ ██████╗ ███████╗███████╗███████╗ +# ██╔══██╗██╔════╝██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██╔════╝ +# ██████╔╝█████╗ ███████╗█████╗ ██║ ██║ ██║█████╗ █████╗ ███████╗ +# ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║ ██║ ██║██╔══╝ ██╔══╝ ╚════██║ +# ██║ ██║███████╗███████║███████╗ ██║ ██████╔╝███████╗██║ ███████║ +# ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ +# +# These are the values that the various "RESET" commands will reset too rather than the built-in defaults. The lenght +# of the lists must match the number of gates on your MMU +# +# e.g. MMU_GATE_MAP RESET=1 - will use all the 'gate_XXX' values +# MMU_TTG_MAP RESET=1 - will use the 'tool_to_gate_map' +# MMU_ENDLESS_SPOOL_GROUPS RESET=1 - will use the 'endless_spool_groups' +# +# Gate: #0 #1 #2 #3 #4 #5 #6 #7 #8 +#gate_status: 1, 0, 1, 2, 2, -1, -1, 0, 1 +#gate_filament_name: one, two, three, four, five, six, seven, eight, nine +#gate_material: PLA, ABS, ABS, ABS+, PLA, PLA, PETG, TPU, ABS +#gate_color: red, black, yellow, green, blue, indigo, ffffff, grey, black +#gate_temperature: 210, 240, 235, 245, 210, 200, 215, 240, 240 +#gate_spool_id: 3, 2, 1, 4, 5, 6, 7, -1, 9 +#gate_speed_override: 100, 100, 100, 100, 100, 100, 100, 50, 100 +#endless_spool_groups: 0, 1, 2, 1, 0, 0, 3, 4, 1 +# +# Tool: T0 T1 T2 T3 T4 T5 T6 T7 T8 +#tool_to_gate_map: 0, 1, 2, 3, 4, 5, 6, 7, 8 diff --git a/config/base/mmu_purge.cfg b/config/base/mmu_purge.cfg new file mode 100644 index 000000000000..b2a23c37e574 --- /dev/null +++ b/config/base/mmu_purge.cfg @@ -0,0 +1,95 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Standalone (very simplistic reference) filament purging +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# When using this macro in print it is important to turn off the wipetower in your slicer +# (read the wiki: Slicer Setup & Toolchange-Movement pages) +# Then set the following parameters in mmu_parameters.cfg: +# +# purge_macro: _MMU_PURGE +# force_purge_standalone: 1 +# +[gcode_macro _MMU_PURGE] +description: Simple reference filament purge + +gcode: + # Happy Hare retraction settings from sequence macros + {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set retracted_length = park_vars.retracted_length %} + {% set retract_speed = sequence_vars.retract_speed|int %} + {% set unretract_speed = sequence_vars.unretract_speed|int %} + + # Happy Hare provided purge data + {% set toolchange_purge_volume = printer.mmu.toolchange_purge_volume|default(0)|float %} + {% set extruder_filament_remaining = printer.mmu.extruder_filament_remaining|default(0)|float %} + + # Not used in reference macro but full purge volume matrix from the slicer can be loaded like this + # https://github.com/moggieuk/Happy-Hare/wiki/Gcode-Preprocessing) + {% set pv = printer.mmu.slicer_tool_map.purge_volumes %} + + # Calculate amount of filament to purge + {% set filament_diameter = printer.configfile.config.extruder.filament_diameter|float %} + {% set filament_cross_section = (filament_diameter / 2) ** 2 * 3.1415 %} + {% set purge_len = toolchange_purge_volume / filament_cross_section %} + {% set segment_len = 2.0 %} + + # Undo Happy Hare retraction before starting purge + {% if retracted_length > 0 %} + MMU_LOG MSG="Un-retracting {retracted_length}mm" + M83 ; Extruder relative + G1 E{retracted_length} F{unretract_speed|abs * 60} + {% endif %} + + {% if extruder_filament_remaining > 0 %} + MMU_LOG MSG="Purging {purge_len | round(1)}mm of filament including {extruder_filament_remaining | round(1)}mm fragment remaining in extruder (Total volume {(filament_cross_section * purge_len) | round(1)}mm³)" + {% else %} + MMU_LOG MSG="Purging {purge_len | round(1)}mm of filament (Total volume {(filament_cross_section * purge_len) | round(1)}mm³)" + {% endif %} + + # Purge in segments so it is still possible to detect clogs and pause + {% set num_segments = (purge_len // segment_len) | int %} + {% for _ in range(num_segments) %} + __MMU_PURGE_SEGMENT LENGTH={segment_len} + {% endfor %} + __MMU_PURGE_SEGMENT LENGTH={purge_len % segment_len} + + # Retract to match what Happy Hare is expecting + {% if retracted_length > 0 %} + MMU_LOG MSG="Retracting {retracted_length}mm" + M83 ; Extruder relative + G1 E-{retracted_length} F{retract_speed|abs * 60} + {% endif %} + + # Adjust tension and centre sensor to ensure we don't accidentally trigger FlowGuard runout when print resumes + {% if printer.mmu.sync_feedback_enabled %} + MMU_SYNC_FEEDBACK ADJUST_TENSION=1 + {% endif %} + + MMU_LOG MSG="Purging complete" + + +# Helper that allows for check of "runout/clog" indicator +[gcode_macro __MMU_PURGE_SEGMENT] +gcode: + {% set vars = printer['gcode_macro _MMU_PURGE_VARS'] %} + {% set extruder_purge_speed = vars['extruder_purge_speed']|float %} + {% set length = params.LENGTH|float %} + {% set clog_runout_detected = printer.mmu.clog_runout_detected|default(false)|lower == 'true' %} # TODO Future + + {% if not clog_runout_detected %} + G1 E{length} F{extruder_purge_speed * 60} + {% endif %} diff --git a/config/base/mmu_sequence.cfg b/config/base/mmu_sequence.cfg new file mode 100644 index 000000000000..9f3ce822bfa2 --- /dev/null +++ b/config/base/mmu_sequence.cfg @@ -0,0 +1,665 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Control of parking and loading and unload sequences +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# These skeleton macros define all the callbacks made during filament loading or unloading. They can be extended with +# the user command additions (see 'mmu_macro_vars.cfg') or can be used as templates for completely custom macros. Note +# the SAVE/RESTORE_GCODE_STATE wrapper pattern is precautionary +# +# The ordering of these macros is as follows (if any are not defined they are skipped): +# +# Unloading sequence... +# _MMU_PRE_UNLOAD Called before starting the unload +# 'form_tip_macro' User defined macro for tip forming +# _MMU_POST_FORM_TIP Called immediately after tip forming +# (_MMU_UNLOAD_SEQUENCE) Advanced: Optionally called based on 'gcode_unload_sequence' +# _MMU_POST_UNLOAD Called after unload completes +# +# Loading sequence... +# _MMU_PRE_LOAD Called before starting the load +# (_MMU_LOAD_SEQUENCE) Advanced: Optionally called based on 'gcode_load_sequence' +# _MMU_POST_LOAD Called after the load is complete +# +# If changing a tool the unload sequence will be immediately followed by the load sequence +# +# Notes: +# 1. When the MMU is enabled Happy Hare implement z-hop and retraction portion of the moves on toolchange, runout, +# pauses and mmu errors (while in print) as well as the un-retraction and return to print positioning. The reason +# for this is print quality, speed and safety. However the configuration is still defined in mmu_macro_vars. +# 2. Pressure advance will automatically be cleared prior and restored after tip forming +# 3. M220 & M221 overrides will be retained after a toolchange +# 4. If configured, Spoolman will be notified of toolchange +# 5. Should an error occur causing a pause, the extruder temp will be saved and restored on MMU_UNLOCK or resume +# +# When the MMU is disabled, the supplied client_macros.cfg will take over retraction and z-hop moves using the +# same configuration which is why they are recommended rather than using alternatives +# +# Leveraging the user defined macro callbacks is usually sufficient for customization, however if you really want to +# do something unusual you can enable the gcode loading/unloading sequences by setting the following in 'mmu_parameters.cfg' +# +# 'gcode_load_sequence: 1' +# 'gcode_unload_sequence: 1' +# +# This is quite advanced and you will need to understand the Happy Hare state machine before embarking on changes. +# Reading the doc is essential +# + + +########################################################################### +# Shared toolhead parking macro designed to position toolhead at a suitable +# parking position, manage z-hops and retraction +# +# Standalone use, typically: _MMU_PARK FORCE_PARK=1 X= Y= Z_HOP= +# +# FORCE_PARK=1 - force parking move +# X | float - x coordinate of forced parking location +# Y | float - y coordinate of forced parking location +# Z_HOP | float - z-hop for forced parking move +# +# OPERATION | string - define operation being performed for built-in parking +# +[gcode_macro _MMU_PARK] +description: Park toolhead safely away from print + +# -------------------------- Internal Don't Touch ------------------------- +variable_saved_xyz: 0, 0, 0 +variable_saved_pos: False # Saved pos valid? +variable_park_operation: '' # If parked, what operation was responsible +variable_next_xy: 0, 0 # Next x,y pos if next_pos is True +variable_next_pos: False # Restore to next_pos? +variable_min_lifted_z: 0 # Supports rising "z-lifted floor" for sequential printing +variable_toolchange_z: -1 # Current calculated toolchange/movement plane +variable_retracted_length: 0 # Amount of current retraction + +gcode: + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set operation = params.OPERATION|default(printer.mmu.operation)|string|lower %} + {% set is_printing = printer.mmu.print_state in ["started", "printing"] and not printer.pause_resume.is_paused %} + {% set force_park = params.FORCE_PARK|default(0)|int == 1 %} + {% set force_park_x = params.X|default(-999)|float %} + {% set force_park_y = params.Y|default(-999)|float %} + {% set enable_park_printing = (vars.enable_park_printing|default("toolchange,runout,pause,cancel")|string|lower).split(",") %} + {% set enable_park_standalone = (vars.enable_park_standalone|default("toolchange,load,unload,pause,cancel")|string|lower).split(",") %} + {% set enable_park_disabled = (vars.enable_park_disabled|default("pause,cancel")|string|lower).split(",") %} + {% set nx,ny = next_xy|map('float') %} + {% set pos = printer.gcode_move.gcode_position %} + {% if force_park %} + {% set operation = 'force_park' %} + {% set x = force_park_x %} + {% set y = force_park_y %} + {% set park_z_hop = params.Z_HOP|default(0)|float %} + {% set z_hop_ramp = params.RAMP|default(0)|float %} + {% set retract = 0 %} + {% elif operation in ['toolchange','load','unload'] %} + {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_toolchange|default([-999,-999,0,0,0])|map('float') %} + {% elif operation in ['runout'] %} + {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_runout|default([-999,-999,0,0,0])|map('float') %} + {% elif operation in ['pause'] %} + {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_pause|default([-999,-999,0,0,0])|map('float') %} + {% elif operation in ['cancel'] %} + {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_cancel|default([-999,-999,0,0,0])|map('float') %} + {% elif operation in ['complete'] %} + {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_complete|default([-999,-999,0,0,0])|map('float') %} + {% else %} + {% set x = -999 %} + {% set y = -999 %} + {% set park_z_hop = 0 %} + {% set z_hop_ramp = 0 %} + {% set retract = 0 %} + {% endif %} + {% set park_z_hop = park_z_hop|abs %} + {% set z_hop_ramp = z_hop_ramp|abs %} + {% set min_toolchange_z = vars.min_toolchange_z|default(1)|float|abs %} + {% if x == -999 and y == -999 and park_z_hop == 0 %} + {% set min_toolchange_z = 0 %} + {% endif %} + {% set park_travel_speed = vars.park_travel_speed|default(200)|float * 60 %} + {% set park_lift_speed = vars.park_lift_speed|default(15)|float * 60 %} + {% set pos = printer.gcode_move.gcode_position %} + {% set origin = printer.gcode_move.homing_origin %} + {% set max = printer.toolhead.axis_maximum %} + + # Z-hop ramp position calcs + {% set orig_x = pos.x %} + {% set orig_y = pos.y %} + {% set cx = (max.x - origin.x) / 2.0 %} + {% set cy = (max.y - origin.y) / 2.0 %} + {% if pos.x == cx and pos.y == cy %} + {% set target_x = 0 %} + {% set target_y = 0 %} + {% else %} + {% set target_x = cx %} + {% set target_y = cy %} + {% endif %} + {% set dx = target_x - pos.x %} + {% set dy = target_y - pos.y %} + {% set length = (dx * dx + dy * dy) ** 0.5 %} + {% set z_hop_x = pos.x + z_hop_ramp * dx / length %} + {% set z_hop_y = pos.y + z_hop_ramp * dy / length %} + + {% set starting_z = saved_xyz[2] if saved_pos else pos.z %} + {% set z_hop_floor = [starting_z, min_lifted_z]|max %} + {% set toolchange_z = [[z_hop_floor + park_z_hop, max.z - origin.z]|min, min_toolchange_z, toolchange_z]|max %} + + {% set should_park = force_park or ( + operation in ( + enable_park_disabled if not printer.mmu.enabled else + (enable_park_printing if is_printing else enable_park_standalone) + ) and operation != park_operation + ) %} + {% set should_ramp = ( + should_park and pos.z == starting_z and + park_z_hop > 0 and z_hop_ramp > 0 and + operation not in ['load'] and is_printing + ) %} + {% set should_retract = ( + should_park and + retracted_length == 0 and retract > 0 and + operation not in ['load'] + ) %} + + {% if should_park %} + {% if should_retract %} + _MMU_RETRACT LENGTH={retract} + {% endif %} + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=toolchange_z VALUE={toolchange_z} + {% set lift=" with ramping lift" if should_ramp else "" %} + MMU_LOG MSG="Parking toolhead at (x:{x|round(1) if x != -999 else "n/a"}, y:{y|round(1) if y != -999 else "n/a"}, z:{toolchange_z|round(1)}){lift} for {operation} operation" + {% if 'xy' not in printer.toolhead.homed_axes %} + MMU_LOG MSG="Cannot park because XY not homed" + {% else %} + G90 # Absolute + {% if 'z' not in printer.toolhead.homed_axes %} + MMU_LOG MSG="Skipping z_hop move because Z not homed" + {% else %} + {% if should_ramp %} + G1 X{z_hop_x} Y{z_hop_y} Z{toolchange_z} F{park_travel_speed} # Ramping Z lift + G1 X{orig_x} Y{orig_y} Z{toolchange_z} F{park_travel_speed} # Restore starting X,Y + {% else %} + G1 Z{toolchange_z} F{park_lift_speed} # Z lift to toolchange plane + {% endif %} + {% endif %} + {% if vars.user_park_move_macro %} + {vars.user_park_move_macro} X={x} Y={y} F={park_travel_speed} + {% elif x != -999 and y != -999 %} + G1 X{x} Y{y} F{park_travel_speed} # Move to park position + {% elif x != -999 and y == -999 %} + G1 X{x} F{park_travel_speed} # X move only + {% elif x == -999 and y != -999 %} + G1 Y{y} F{park_travel_speed} # Y move only + {% endif %} + {% endif %} + {% if not force_park %} + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=park_operation VALUE=\"{operation}\" + {% endif %} + {% endif %} + + +########################################################################### +# Helper macro: save current toolhead position +# This macro is idempotent recording only the first position until cleared +# This is often called directly by Happy Hare +# +[gcode_macro _MMU_SAVE_POSITION] +description: Record to toolhead position for return later +gcode: + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set pos = printer.gcode_move.gcode_position %} + {% set axis_minimum = printer.toolhead.axis_minimum %} + {% set axis_maximum = printer.toolhead.axis_maximum %} + {% set x = [axis_minimum.x, [axis_maximum.x, pos.x]|min]|max %} + {% set y = [axis_minimum.y, [axis_maximum.y, pos.y]|min]|max %} + + {% if not park_vars.saved_pos and 'xyz' in printer.toolhead.homed_axes %} + {% if x != pos.x or y != pos.y %} + MMU_LOG MSG="Warning: Klipper reported out of range gcode position (x:{pos.x}, y:{pos.y})! Adjusted to (x:{x}, y:{y}) to prevent move failure" ERROR=1 + {% endif %} + MMU_LOG MSG="Saving toolhead position (x:{x|round(1)}, y:{y|round(1)}, z:{pos.z|round(1)})" + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=saved_xyz VALUE="{x}, {y}, {pos.z}" + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=saved_pos VALUE={True} + {% endif %} + + +########################################################################### +# Helper macro: restore previously saved position and reset +# This is often called directly by Happy Hare +# +[gcode_macro _MMU_RESTORE_POSITION] +description: Restore saved toolhead position +gcode: + {% set restore = params.RESTORE|default(1)|int %} + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set x,y,z = park_vars.saved_xyz|map('float') %} + {% set nx,ny = park_vars.next_xy|map('float') %} + {% set park_travel_speed = vars.park_travel_speed|default(200)|float * 60 %} + {% set park_lift_speed = vars.park_lift_speed|default(15)|float * 60 %} + {% set restore_xy_pos = vars.restore_xy_pos|default('last') %} + {% set is_printing = printer.mmu.print_state in ["started", "printing"] and not printer.pause_resume.is_paused %} + + {% set should_unretract = ( + park_vars.retracted_length and + park_vars.park_operation not in ['unload', 'cancel', 'complete'] + ) %} + {% set should_restore_nextxy = ( + park_vars.next_pos and + restore_xy_pos == 'next' and + park_vars.park_operation == 'toolchange' + ) %} + {% set should_restore_xy = ( + park_vars.saved_pos and + (restore_xy_pos != 'none' or park_vars.park_operation != 'toolchange') + ) %} + + {% if restore %} # Allows override for initial tool load and final unload + G90 # Absolute + {% if park_vars.toolchange_z > 0 and park_vars.saved_pos and 'z' in printer.toolhead.homed_axes %} + G1 Z{park_vars.toolchange_z} F{park_lift_speed} # Ensure at toolchange height for collision avoidance before move + {% endif %} + {% if should_restore_nextxy %} + {% if vars.user_park_move_macro %} + MMU_LOG MSG="Restoring toolhead position to next pos using {vars.user_park_move_macro} RESTORE=1 (x:{nx|round(1)}, y:{ny|round(1)})" + {vars.user_park_move_macro} RESTORE=1 X={nx} Y={ny} F={park_travel_speed} + {% else %} + MMU_LOG MSG="Restoring toolhead position to next pos: (x:{nx|round(1)}, y:{ny|round(1)}, z:{z|round(1)})" + G1 X{nx} Y{ny} F{park_travel_speed} # Restore X,Y to next print position + {% endif %} + {% elif should_restore_xy %} + {% if vars.user_park_move_macro %} + MMU_LOG MSG="Restoring toolhead position to last pos using {vars.user_park_move_macro} RESTORE=1 (x:{x|round(1)}, y:{y|round(1)})" + {vars.user_park_move_macro} RESTORE=1 X={x} Y={y} F={park_travel_speed} + {% else %} + MMU_LOG MSG="Restoring toolhead position to last pos: (x:{x|round(1)}, y:{y|round(1)}, z:{z|round(1)})" + G1 X{x} Y{y} F{park_travel_speed} # Restore X,Y to last (starting) position + {% endif %} + {% elif park_vars.saved_pos %} + MMU_LOG MSG="Restoring toolhead position to: (z:{z|round(1)})" + {% endif %} + {% if park_vars.saved_pos and 'z' in printer.toolhead.homed_axes %} + G1 Z{z} F{park_lift_speed} # Restore original Z height + {% endif %} + {% else %} + MMU_LOG MSG="Skipping restore of toolhead XYZ position" + {% endif %} + + {% if should_unretract %} + _MMU_UNRETRACT + {% endif %} + + _MMU_CLEAR_POSITION + + +########################################################################### +# Helper macro: Retract filament +# +[gcode_macro _MMU_RETRACT] +description: Helper to retract filament +gcode: + {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set retracted_length = park_vars.retracted_length %} + {% set length = params.LENGTH|default(0)|float|abs %} + {% set speed = sequence_vars.retract_speed|int %} + {% set length = [length - retracted_length, 0] | max %} + + {% if printer.extruder.can_extrude %} + {% if length > 0 %} + MMU_LOG MSG="Retracting {length}mm" + M83 ; Extruder relative + G1 E-{length} F{speed|abs * 60} + {% endif %} + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=retracted_length VALUE={length} + {% else %} + MMU_LOG MSG="Extruder is not hot enough to retract" DEBUG=1 + {% endif %} + + +########################################################################### +# Helper macro: Undo retract filament move +# +[gcode_macro _MMU_UNRETRACT] +description: Helper to extruder filament to undo retract +gcode: + {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set length = park_vars.retracted_length %} + {% set speed = sequence_vars.unretract_speed|int %} + + {% if printer.extruder.can_extrude %} + {% if length > 0 %} + MMU_LOG MSG="Un-retracting {length}mm" + M83 ; Extruder relative + G1 E{length} F{speed|abs * 60} + {% endif %} + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=retracted_length VALUE=0 + {% else %} + MMU_LOG MSG="Extruder is not hot enough to un-retract" DEBUG=1 + {% endif %} + + +########################################################################### +# Helper macro: clear previously saved position +# +[gcode_macro _MMU_CLEAR_POSITION] +description: Clear previously recorded toolhead position +gcode: + {% set reset = params.RESET|default(0)|int %} + + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=saved_pos VALUE={False} + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=next_pos VALUE={False} + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=toolchange_z VALUE=-1 + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=retracted_length VALUE=0 + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=park_operation VALUE=\"\" + {% if reset %} + # Reset rising floor used in sequential printing + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=min_lifted_z VALUE=0 + {% endif %} + MMU_LOG MSG="Clearing saved toolhead position{' and rising toolchange floor' if reset else ''}" DEBUG=1 + + +########################################################################### +# Helper macro: option to auto home toolhead +# +[gcode_macro _MMU_AUTO_HOME] +description: Convenience auto homing primarily for testing +gcode: + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set auto_home = vars.auto_home|default(true)|lower == 'true' %} + + {% if auto_home and 'xyz' not in printer.toolhead.homed_axes %} + MMU_LOG MSG="Automatically homing XYZ before parking toolhead" + G28 + {% endif %} + + +########################################################################### +# Helper macro: record maximum toolhead height +# Designed to be called from slicer layer changed logic +# +[gcode_macro MMU_UPDATE_HEIGHT] +description: Record maximum toolhead height for z-hop base (call on layer change for sequential printing) +gcode: + {% set height = params.HEIGHT|default(0)|float %} + {% set park_vars = printer['gcode_macro _MMU_PARK'] %} + {% set max_z = [park_vars.min_lifted_z, printer.gcode_move.gcode_position.z, height]|max %} + + {% if max_z > park_vars.min_lifted_z %} + MMU_LOG MSG="Setting rising toolchange floor to {max_z}" DEBUG=1 + {% endif %} + SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=min_lifted_z VALUE={max_z} + + +########################################################################### +# This occurs prior to unloading filament on a toolchange +# +[gcode_macro _MMU_PRE_UNLOAD] +description: Optional pre unload routine for filament change +gcode: + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set x, y, z_hop = vars.pre_unload_position|default([-999,-999,0])|map('float') %} + + {% if x != -999 or y != -999 or z_hop > 0 %} + _MMU_PARK FORCE_PARK=1 X={x} Y={y} Z_HOP={z_hop} + {% endif %} + {vars.user_pre_unload_extension|default("")} + + +########################################################################### +# This occurs immediately after the tip forming or cutting procedure +# +[gcode_macro _MMU_POST_FORM_TIP] +description: Optional post tip forming/cutting routing +gcode: + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set x, y, z_hop = vars.post_form_tip_position|default([-999,-999,0])|map('float') %} + + {% if x != -999 or y != -999 or z_hop > 0 %} + _MMU_PARK FORCE_PARK=1 X={x} Y={y} Z_HOP={z_hop} + {% endif %} + {vars.user_post_form_tip_extension|default("")} + + +########################################################################### +# This occurs immediately after unloading filament on a toolchange +# +[gcode_macro _MMU_POST_UNLOAD] +description: Optional post unload routine for filament change +gcode: + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + + # This is a good place to inject logic to, for example, perform tip + # cutting when cutter is located at the MMU, thus prepping the unloaded + # filament for next use (e.g. EREC) + {vars.user_post_unload_extension|default("")} + + +########################################################################### +# This occurs prior to starting the load sequence on a toolchange +# +[gcode_macro _MMU_PRE_LOAD] +description: Optional pre load routine for filament change +gcode: + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set x, y, z_hop = vars.pre_load_position|default([-999,-999,0])|map('float') %} + + {% if x != -999 or y != -999 or z_hop > 0 %} + _MMU_PARK FORCE_PARK=1 X={x} Y={y} Z_HOP={z_hop} + {% endif %} + {vars.user_pre_load_extension|default("")} + + +########################################################################### +# This occurs after loading new filament on a toolchange +# +[gcode_macro _MMU_POST_LOAD] +description: Optional post load routine for filament change +gcode: + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {% set timelapse = vars.timelapse|default(false)|lower == 'true' %} + + {% if timelapse %} + TIMELAPSE_TAKE_FRAME + {% endif %} + + # A good place to implement custom purging logic and/or nozzle cleaning + # prior to returning to print/wipetower (e.g. Blobifier) + {vars.user_post_load_extension|default("")} + + +########################################################################### +# This is called when a MMU error occurs +# +[gcode_macro _MMU_ERROR] +description: Called when an MMU error occurs +gcode: + {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} + {vars.user_mmu_error_extension|default("")} + + +########################################################################### +# ADVANCED ADVANCED ADVANCED ADVANCED ADVANCED ADVANCED +# User modifiable loading and unloading sequences +# +# By default Happy Hare will call internal logic to handle loading and unloading +# sequences. To enable the calling of user defined sequences you must add the +# following to your mmu_parameters.cfg +# +# gcode_load_sequence: 1 # Gcode loading sequence 1=enabled, 0=internal logic (default) +# gcode_unload_sequence: 1 # Gcode unloading sequence, 1=enabled, 0=internal logic (default) +# +# This reference example load sequence mimicks the internal ones exactly. It uses the +# high level "modular" movements that are all controlled by parameters defined in +# mmu_parameters.cfg and automatically keep the internal filament position state up-to-date. +# Switching to these macros should not change behavior and can serve as a starting point for +# your customizations +# +# State Machine: +# If you experiment beyond the basic example shown here you will need to understand +# the possible states for filament position. This is the same state that is exposed +# as the `printer.mmu.filament_pos` printer variable. This internal state must be +# kept up-to-date and will need to be set directly as you progress through your +# custom move sequence. At this time the state machine is non-extensible. +# +# FILAMENT_POS_UNKNOWN = -1 +# L ^ FILAMENT_POS_UNLOADED = 0 +# O | FILAMENT_POS_HOMED_GATE = 1 # If gate sensor fitted +# A | FILAMENT_POS_START_BOWDEN = 2 +# D | FILAMENT_POS_IN_BOWDEN = 3 +# FILAMENT_POS_END_BOWDEN = 4 +# | U FILAMENT_POS_HOMED_ENTRY = 5 # If extruder (entry) sensor fitted +# | N FILAMENT_POS_HOMED_EXTRUDER = 6 +# | L FILAMENT_POS_PAST_EXTRUDER = 7 +# | O FILAMENT_POS_HOMED_TS = 8 # If toolhead sensor fitted +# | A FILAMENT_POS_IN_EXTRUDER = 9 # AKA Filament is past the Toolhead Sensor +# v D FILAMENT_POS_LOADED = 10 # AKA Filament is homed to the nozzle +# +# Final notes: +# 1) You need to respect the context being passed into the macro such as the +# desired 'length' to move because this can be called for test loading +# 2) The unload macro can be called with the filament in any position (states) +# You are required to handle any starting point. The default reference +# serves as a good guide +# +[gcode_macro _MMU_LOAD_SEQUENCE] +description: Called when MMU is asked to load filament +gcode: + {% set filament_pos = params.FILAMENT_POS|float %} + {% set length = params.LENGTH|float %} + {% set full = params.FULL|int %} + {% set home_extruder = params.HOME_EXTRUDER|int %} + {% set skip_extruder = params.SKIP_EXTRUDER|int %} + {% set extruder_only = params.EXTRUDER_ONLY|int %} + + {% if extruder_only %} + _MMU_STEP_LOAD_TOOLHEAD EXTRUDER_ONLY=1 + + {% elif filament_pos >= 7 %} # FILAMENT_POS_PAST_EXTRUDER + {action_raise_error("Can't load - already in extruder!")} + + {% else %} + {% if filament_pos <= 0 %} # FILAMENT_POS_UNLOADED + _MMU_STEP_LOAD_GATE + {% endif %} + + {% if filament_pos < 4 %} # FILAMENT_POS_END_BOWDEN + _MMU_STEP_LOAD_BOWDEN LENGTH={length} + {% endif %} + + {% if filament_pos < 6 and home_extruder %} # FILAMENT_POS_HOMED_EXTRUDER + _MMU_STEP_HOME_EXTRUDER + {% endif %} + + {% if not skip_extruder %} # FILAMENT_POS_PAST_EXTRUDER + _MMU_STEP_LOAD_TOOLHEAD + {% endif %} + + {% endif %} + +[gcode_macro _MMU_UNLOAD_SEQUENCE] +description: Called when MMU is asked to unload filament +gcode: + {% set filament_pos = params.FILAMENT_POS|float %} + {% set length = params.LENGTH|float %} + {% set extruder_only = params.EXTRUDER_ONLY|int %} + {% set park_pos = params.PARK_POS|float %} + + {% if extruder_only %} + {% if filament_pos >= 7 %} # FILAMENT_POS_PAST_EXTRUDER + _MMU_STEP_UNLOAD_TOOLHEAD EXTRUDER_ONLY=1 PARK_POS={park_pos} + {% else %} + {action_raise_error("Can't unload extruder - already unloaded!")} + {% endif %} + + {% elif filament_pos == 0 %} + {action_raise_error("Can't unload - already unloaded!")} + + {% else %} + {% if filament_pos >= 7 %} # FILAMENT_POS_PAST_EXTRUDER + # Exit extruder, fast unload of bowden, then slow unload encoder + _MMU_STEP_UNLOAD_TOOLHEAD PARK_POS={park_pos} + {% endif %} + + {% if filament_pos >= 4 %} # FILAMENT_POS_END_BOWDEN + # Fast unload of bowden, then slow unload encoder + _MMU_STEP_UNLOAD_BOWDEN FULL=1 + _MMU_STEP_UNLOAD_GATE + + {% elif filament_pos >= 2 %} # FILAMENT_POS_START_BOWDEN + # Have to do slow unload because we don't know exactly where in the bowden we are + _MMU_STEP_UNLOAD_GATE FULL=1 + {% endif %} + + {% endif %} + +# +# Some examples of alternative macros follow +# +# 1. This loading example leverages the built-in modules to load filament to the end +# of the bowden tube. Then homes the filament to the toolhead sensor (toolhead) +# using synchronized gear and extruder movement. The state is updated to reflect this +# new position. It then performs a synchronized stepper move of 62mm to advance the +# filament to the nozzle +# +#[gcode_macro _MMU_LOAD_SEQUENCE] +#description: Called when MMU is asked to load filament +#gcode: +# {% set filament_pos = params.FILAMENT_POS|float %} +# {% set length = params.LENGTH|float %} +# {% set skip_extruder = params.SKIP_EXTRUDER|int %} +# {% set extruder_only = params.EXTRUDER_ONLY|int %} +# +# {% if extruder_only %} +# _MMU_STEP_HOMING_MOVE ENDSTOP=toolhead MOVE=50 MOTOR=extruder +# _MMU_STEP_SET_FILAMENT STATE=8 # FILAMENT_POS_HOMED_TS +# _MMU_STEP_MOVE MOVE=62 MOTOR=extruder +# _MMU_STEP_SET_FILAMENT STATE=10 # FILAMENT_POS_LOADED +# {% else %} +# _MMU_STEP_LOAD_GATE +# _MMU_STEP_LOAD_BOWDEN LENGTH={length} +# {% if full and not skip_extruder %} +# _MMU_STEP_HOMING_MOVE ENDSTOP=toolhead MOVE=50 MOTOR=gear+extruder +# _MMU_STEP_SET_FILAMENT STATE=8 # FILAMENT_POS_HOMED_TS +# _MMU_STEP_MOVE MOVE=62 MOTOR=gear+extruder +# _MMU_STEP_SET_FILAMENT STATE=10 # FILAMENT_POS_LOADED +# {% endif %} +# {% endif %} +# +# +# 2. This very streamlined loading example starts off similarly loading to the end of the +# calibrated bowden. It then simply homes to the nozzle (using TMC stallguard on the extruder +# stepper!) with synchronized extruder+gear steppers. This requires the `mmu_ext_touch` +# endstop to be defined for the EXTRUDER stepper (this is possible with Happy Hare extension) +# +#[gcode_macro _MMU_LOAD_SEQUENCE] +#description: Called when MMU is asked to load filament +#gcode: +# {% set length = params.LENGTH|float %} +# {% set full = params.FULL|int %} +# {% set skip_extruder = params.SKIP_EXTRUDER|int %} +# {% set extruder_only = params.EXTRUDER_ONLY|int %} +# +# {% if extruder_only %} +# _MMU_STEP_HOMING_MOVE ENDSTOP=mmu_ext_touch MOVE=100 MOTOR=extruder +# _MMU_STEP_SET_FILAMENT STATE=10 # FILAMENT_POS_LOADED +# {% else %} +# _MMU_STEP_LOAD_GATE +# _MMU_STEP_LOAD_BOWDEN LENGTH={length} +# {% if full and not skip_extruder %} +# _MMU_STEP_HOMING_MOVE ENDSTOP=mmu_ext_touch MOVE=100 MOTOR=extruder+gear +# _MMU_STEP_SET_FILAMENT STATE=10 # FILAMENT_POS_LOADED +# {% endif %} +# {% endif %} + diff --git a/config/base/mmu_software.cfg b/config/base/mmu_software.cfg new file mode 100644 index 000000000000..98ecf345e8da --- /dev/null +++ b/config/base/mmu_software.cfg @@ -0,0 +1,567 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Miscellaneous supporting macros +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# + + +########################################################################### +# Convenience print start marco that users can call directly from their +# slicer's custom "start g-code" or call from existing start marco +# +# To call from slicer (recommended), add these lines to your custom start +# g-code (before and after the call to your regular print start macro). +# It is recommended to separate the filament purge portion of the start +# sequence until after the initial tool is loaded. +# +# Slicer: Custom Start g-code +# +----------------------------------------------------------+ +# | ; Initialize MMU and save info from gcode file | +# | MMU_START_SETUP INITIAL_TOOL={initial_tool} | +# | REFERENCED_TOOLS=!referenced_tools! | +# | TOOL_COLORS=!colors! | +# | TOOL_TEMPS=!temperatures! | +# | TOOL_MATERIALS=!materials! | +# | FILAMENT_NAMES=!filament_names! | +# | PURGE_VOLUMES=!purge_volumes! | +# | | +# | ; Check MMU is setup for the slicer defined print | +# | MMU_START_CHECK | +# | | +# | ; Bed leveling, heating logic, etc for print start | +# | ; (Nothing that requires filament in extruder) | +# | PRINT_START ; call you existing macro here.. | +# | | +# | ; Load slicer defined initial tool into MMU | +# | MMU_START_LOAD_INITIAL_TOOL | +# | | +# | ; Final purge logic before starting to print | +# | ..optionally call you purge logic start macro.. | +# +----------------------------------------------------------+ +# +# NOTE: The reason that it is recommended to add these 4 or 5 lines to your +# slicer is to keep them as separate gcode macros to enable the print to +# pause in the case of an error. If you bundle everything into a single +# print start macro then the first opportunity to pause will be at the end +# of that, potentially long running, macro! +# +# Alternatively you can pass in the params to your existing print start +# macro and then insert these calls in that macro (but not recommended +# because of pause warning above) +# +# MMU_START_SETUP {rawparams} +# MMU_START_CHECK +# MMU_START_LOAD_INITIAL_TOOL +# +[gcode_macro MMU_START_SETUP] +description: Called when starting print to setup MMU +gcode: + {% set initial_tool = params.INITIAL_TOOL|default(0)|int %} + {% set total_toolchanges = params.TOTAL_TOOLCHANGES|default(0)|int %} + {% set ttg_map = printer.mmu.ttg_map %} + {% set gate_fil_names = printer.mmu.gate_filament_name %} + {% set gate_colors = printer.mmu.gate_color %} + {% set num_gates = ttg_map|length %} + {% set referenced_tools = (params.REFERENCED_TOOLS|default("!referenced_tools!")|string).split(",") + if (params.REFERENCED_TOOLS and params.REFERENCED_TOOLS != "") + else [] %} + {% set tool_colors = (params.TOOL_COLORS|default("")|string).split(",") + if (params.TOOL_COLORS and params.TOOL_COLORS != "!colors!" and params.TOOL_COLORS != "") + else ['000000'] * num_gates %} + {% set tool_temps = (params.TOOL_TEMPS|default("")|string).split(",") + if (params.TOOL_TEMPS and params.TOOL_TEMPS != "!temperatures!" and params.TOOL_TEMPS != "") + else ['0'] * num_gates %} + {% set tool_materials = (params.TOOL_MATERIALS|default("")|string).split(",") + if (params.TOOL_MATERIALS and params.TOOL_MATERIALS != "!materials!" and params.TOOL_MATERIALS != "") + else ['unknown'] * num_gates %} + {% set filament_names = (params.FILAMENT_NAMES|default("")|string).split(",") + if (params.FILAMENT_NAMES and params.FILAMENT_NAMES != "!filament_names!" and params.FILAMENT_NAMES != "") + else [''] * num_gates %} + {% set purge_volumes = (params.PURGE_VOLUMES|default("")|string) + if (params.PURGE_VOLUMES and params.PURGE_VOLUMES != "!purge_volumes!" and params.PURGE_VOLUMES != "") + else "" %} + + {% set vars = printer['gcode_macro _MMU_SOFTWARE_VARS'] %} + {% set home_mmu = vars.home_mmu|lower == 'true' %} + + {% set filament_loaded = printer.mmu.filament_pos == 10 %} + {% set using_bypass = printer.mmu.tool == -2 %} + {% set num_colors = referenced_tools|length %} + + {% if printer.mmu.enabled %} + # Bookend for start of MMU print job. Initializes MMU print state + # Necessary when printing from Octoprint (but harmless if printing from virtual SD card) + MMU_PRINT_START + + # Typically this would be something like a G28 to ensure homing in case of pause + {% if not vars.user_pre_initialize_extension == "" %} + {vars.user_pre_initialize_extension} + {% endif %} + + # Establish number of colors in print and tools being used + {% if referenced_tools == ['!referenced_tools!'] %} + MMU_LOG MSG="Happy Hare gcode pre-processor is probably disabled or not setup correctly" + {% set referenced_tools = [] %} + {% set num_colors = -1 %} + {% elif referenced_tools == [] %} + {% set num_colors = 1 %} + {% endif %} + + # Sanity check the parsed information + {% set num_slicer_tools = tool_colors|length %} + {% if not using_bypass %} + {% if tool_colors|length != tool_temps|length or tool_colors|length != tool_materials|length or tool_colors|length != filament_names|length %} + MMU_LOG MSG="Warning: Slicer defined extruder attributes have different lengths. Possibly an issue with parsing slicer information or missing parameters to MMU_START_SETUP" + MMU_LOG MSG=" TOOL_COLORS={tool_colors}" + MMU_LOG MSG=" TOOL_TEMPS={tool_temps}" + MMU_LOG MSG=" TOOL_MATERIALS={tool_materials}" + MMU_LOG MSG=" FILAMENT_NAMES={filament_names}" + {% endif %} + {% if tool_colors|length != num_gates or tool_temps|length != num_gates or tool_materials|length != num_gates or filament_names|length != num_gates %} + {% if vars.automap_strategy != 'none' %} + MMU_LOG MSG="Warning: Looks like slicer is setup with {num_slicer_tools} extruders but your MMU has {num_gates} gates! Probably using auto-map feature." + {% else %} + MMU_LOG MSG="Warning: Looks like slicer is setup with {num_slicer_tools} extruders but your MMU has {num_gates} gates! These should match but will attempt to continue" + {% endif %} + {% endif %} + {% endif %} + + # Setup slicer tool map + MMU_SLICER_TOOL_MAP RESET=1 PURGE_VOLUMES={purge_volumes} NUM_SLICER_TOOLS={num_slicer_tools} INITIAL_TOOL={initial_tool} TOTAL_TOOLCHANGES={total_toolchanges} + {% if using_bypass %} + MMU_SLICER_TOOL_MAP SKIP_AUTOMAP=1 + {% endif %} + {% for t in range(num_slicer_tools) %} + MMU_SLICER_TOOL_MAP TOOL={t} TEMP={tool_temps[t]} MATERIAL='{tool_materials[t]}' COLOR={tool_colors[t]} NAME='{filament_names[t]}' {"USED=0" if t|string not in referenced_tools and t != initial_tool else ""} QUIET=1 AUTOMAP={vars.automap_strategy} + {% endfor %} + + # Build message in case of error + {% set custom_msg = [] %} + {% set m = [] %} + {% for tool in referenced_tools %} + {% set _ = m.append("T" + tool|string + " (Gate" + ttg_map[tool|int]|string + ")") %} + {% endfor %} + {% set line = "Initial Tool: T%s" % initial_tool %} + {% set _ = m.append(line) %} + {% set _ = custom_msg.append("Print requires tools: %s" % ", ".join(m)) %} + {% set _ = custom_msg.append("Manually ensure that T" + initial_tool|string + " is loaded and all other tools available before resuming print") %} + + # Display map summary + {% if num_colors > 1 %} + MMU_SLICER_TOOL_MAP SPARSE_PURGE_MAP=1 NUM_SLICER_TOOLS={num_slicer_tools} + {% else %} + MMU_SLICER_TOOL_MAP + {% endif %} + + SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=show_abort VALUE={True} # Show abort option during startup + {% if using_bypass and filament_loaded %} + MMU_LOG MSG="MMU Bypass selected and loaded" + {% if num_colors > 1 %} + SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=custom_msg VALUE="{custom_msg}" + MMU_PAUSE MSG="Bypass selected for multi-color print" + {% endif %} + {% else %} + # Preemptively set verbose dialog message in case of additional mmu error during start + SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=custom_msg VALUE="{custom_msg}" + {% if home_mmu %} + {% if not filament_loaded %} + MMU_HOME TOOL={initial_tool} + {% else %} + MMU_LOG MSG="Skipping homing MMU because filament is already loaded" + {% endif %} + {% endif %} + {% endif %} + {% endif %} + SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_setup_run VALUE={True} + + +########################################################################### +# Helper macro to check required gates have filament. This is separated out +# from main setup macro to allow for pausing on previous error first +# +[gcode_macro MMU_START_CHECK] +description: Helper macro. Can be called to perform pre-start checks on MMU based on slicer requirements +gcode: + {% set vars = printer['gcode_macro _MMU_SOFTWARE_VARS'] %} + {% set check_gates = vars.check_gates|lower == 'true' %} + {% set using_bypass = printer.mmu.tool == -2 %} + + {% if printer.mmu.enabled %} + {% set slicer_tool_map = printer.mmu.slicer_tool_map %} + {% set initial_tool = slicer_tool_map.initial_tool %} + {% set tools = slicer_tool_map.referenced_tools %} + {% if not using_bypass %} + # Future: Could do extra checks like filament material type/color checking here + # to ensure what's loaded on MMU matches the slicer expectations + {% if check_gates and tools|length > 0 %} + # Pre-check gates option if multi-color print. Will pause if tools missing + MMU_LOG MSG="Checking all required gates have filament loaded..." + {% if not printer.mmu.is_homed %} + MMU_HOME + {% endif %} + MMU_CHECK_GATE TOOLS={tools|join(",")} + {% endif %} + {% endif %} + {% endif %} + SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_check_run VALUE={True} + + +########################################################################### +# Helper macro to load the initial tool. This is separated out from main +# setup macro to allow for pausing on previous error first +# +[gcode_macro MMU_START_LOAD_INITIAL_TOOL] +description: Helper to load initial tool if not paused +gcode: + {% set vars = printer['gcode_macro _MMU_SOFTWARE_VARS'] %} + {% set load_initial_tool = vars.load_initial_tool|lower == 'true' %} + {% set using_bypass = printer.mmu.tool == -2 %} + {% set filament_loaded = printer.mmu.filament_pos == 10 %} + {% set slicer_tool_map = printer.mmu.slicer_tool_map %} + {% set initial_tool = slicer_tool_map.initial_tool %} + {% set tools = slicer_tool_map.referenced_tools %} + + {% if printer.mmu.enabled %} + {% if not using_bypass and tools|length > 0 %} + {% if load_initial_tool and (initial_tool is not none and initial_tool >= 0) %} + MMU_LOG MSG="Loading initial tool T{initial_tool}..." + MMU_CHANGE_TOOL STANDALONE=1 RESTORE=0 TOOL={initial_tool} + {% endif %} + {% elif not filament_loaded %} + MMU_PAUSE MSG="Load bypass or initial tool before resuming print" + {% else %} + MMU_LOG MSG="Using bypass" + {% endif %} + {% endif %} + + # Important: Clear preemptive error message and remove abort option from pause dialog + SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=custom_msg VALUE='""' + SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=show_abort VALUE={False} + SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_load_initial_tool_run VALUE={True} + + +########################################################################### +# Convenience print end marco that users can call directly from their +# slicer's custom "end g-code" or call from existing end marco +# +# To call from slicer, add this to custom end g-code (possibly as one line +# just after the call to your regular print end macro) or call directly from +# without your existing print end macro: +# +# Slicer: Custom End g-code +# +----------------------------------------------------------+ +# | ; Finalize MMU and optionally park and unload filament | +# | MMU_END | +# | | +# | ; Your existing print end macro | +# | PRINT_END | +# +----------------------------------------------------------+ +# +[gcode_macro MMU_END] +description: Called when ending print to finalize MMU +gcode: + {% set unload = params.UNLOAD|default(0)|int %} + {% set vars = printer['gcode_macro _MMU_SOFTWARE_VARS'] %} + {% set unload_tool = vars.unload_tool|lower == 'true' %} + {% set reset_ttg = vars.reset_ttg|lower == 'true' %} + {% set dump_stats = vars.dump_stats|lower == 'true' %} + {% set slicer_tool_map = printer.mmu.slicer_tool_map %} + {% set tools = slicer_tool_map.referenced_tools %} + {% set using_bypass = printer.mmu.tool == -2 %} + + {% if printer.mmu.enabled %} + {% if not vars.user_print_end_extension == "" %} + {vars.user_print_end_extension} + {% endif %} + + {% if unload or unload_tool %} + MMU_LOG MSG="Unloading filament on print end" + MMU_UNLOAD RESTORE=0 + {% endif %} + + {% if reset_ttg %} + MMU_TTG_MAP RESET=1 QUIET=1 + {% endif %} + + {% if dump_stats and not using_bypass and tools|length > 0 %} + MMU_STATS + {% endif %} + + # Bookend for end of MMU print job. Finalizes MMU state + MMU_PRINT_END STATE=complete + {% endif %} + SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_end_run VALUE={True} + + +########################################################################### +# Helper macro that will walk the user through a cold-pull +# +# Assumes the bowden tube is removed from the toolhead and the extruder +# is loaded with about a 300mm piece of filament. The user should have access +# to the filament to assist pull when asked +# +# Params: +# MATERIAL=nylon|pla|abs|petg Starting temp defaults +# HOT_TEMP Initial high temp +# COLD_TEMP Temp to cool too to help release filament +# MIN_EXTRUDE_TEMP Temp to which the extruder will keep nozzle pressurized +# PULL_TEMP Temp to perform the cold pull +# PULL_SPEED Speed in mm/s of extruder movement to help manual pull +# CLEAN_LENGTH Amount of filament to extrude to prime extruder/nozzle +# EXTRUDE_SPEED Speed in mm/s to perform extrude operations +# +[gcode_macro MMU_COLD_PULL] +description: Guide you through the process of cleaning your extruder with a cold pull +gcode: + {% set material = params.MATERIAL|default("pla")|string|upper %} + {% set materials = { + 'NYLON': {'hot_temp': 260, 'cold_temp': 50, 'pull_temp': 120, 'min_extrude_temp': 190}, + 'PLA': {'hot_temp': 250, 'cold_temp': 45, 'pull_temp': 100, 'min_extrude_temp': 160}, + 'ABS': {'hot_temp': 255, 'cold_temp': 50, 'pull_temp': 120, 'min_extrude_temp': 190}, + 'PETG': {'hot_temp': 250, 'cold_temp': 45, 'pull_temp': 100, 'min_extrude_temp': 180} + } %} + {% if material not in materials %} + {action_raise_error("Unknown material. Valid types are: Nylon, ABS, PLA, PETG")} + {% endif %} + + # Allow individual temperature overrides. Coded like this so Mainsail can parse options + {% set hot_temp = params.HOT_TEMP|default('')|int %} + {% set cold_temp = params.COLD_TEMP|default('')|int %} + {% set pull_temp = params.PULL_TEMP|default('')|int %} + {% set min_extrude_temp = params.MIN_EXTRUDE_TEMP|default('')|int %} + {% set hot_temp = (hot_temp if hot_temp > 0 else materials.get(material).hot_temp)|int %} + {% set cold_temp = (cold_temp if cold_temp > 0 else materials.get(material).cold_temp)|int %} + {% set pull_temp = (pull_temp if pull_temp > 0 else materials.get(material).pull_temp)|int %} + {% set min_extrude_temp = (min_extrude_temp if min_extrude_temp > 0 else materials.get(material).min_extrude_temp)|int %} + + {% set pull_speed = params.PULL_SPEED|default(10)|int %} + {% set clean_length = params.CLEAN_LENGTH|default(25)|int %} + {% set extrude_speed = params.EXTRUDE_SPEED|default(1.5)|float %} + + {% set ns = namespace(stuff_points=[], cool_points=[]) %} + + {% for temp in range(hot_temp + 1, cold_temp - 1, -1) %} + {% if temp % 10 == 0 %} + {% if temp > min_extrude_temp %} + {% set ns.stuff_points = ns.stuff_points + [temp] %} + {% elif temp < min_extrude_temp %} + {% set ns.cool_points = ns.cool_points + [temp] %} + {% endif %} + {% endif %} + {% endfor %} + + MMU_LOG MSG='{"Cold Pull based on %s profile (use MATERIAL= to adjust):" % material}' + MMU_LOG MSG='{"pull_temp=%d\u00B0C, hot_temp=%d\u00B0C, min_extruder=%d\u00B0C, cold_temp=%d\u00B0C" % (pull_temp, hot_temp, min_extrude_temp, cold_temp)}' + + MMU_LOG MSG='{"Heating extruder to %d\u00B0C..." % hot_temp}' + SET_HEATER_TEMPERATURE HEATER="extruder" TARGET={hot_temp} + TEMPERATURE_WAIT SENSOR="extruder" MINIMUM={hot_temp - 2} MAXIMUM={hot_temp + 2} + + # Ensure the nozzle id completely full + MMU_LOG MSG="Cleaning nozzle tip with {clean_length}mm of filament" + _MMU_STEP_MOVE MOTOR="extruder" MOVE={clean_length} SPEED={extrude_speed} ALLOW_BYPASS=1 + + # Begin the cooling ramp + MMU_LOG MSG="Allowing extruder to cool..." + SET_HEATER_TEMPERATURE HEATER="extruder" TARGET={cold_temp} + M106 S255 # 100% part fan to cool faster + + # While filament can still extrude keep the nozzle completely full + {% for temp in ns.stuff_points %} + TEMPERATURE_WAIT SENSOR="extruder" MAXIMUM={temp} + MMU_LOG MSG='{"> Stuffing nozzle at %d\u00B0C" % temp}' + _MMU_STEP_MOVE MOTOR="extruder" MOVE=1 SPEED={extrude_speed} ALLOW_BYPASS=1 + {% endfor %} + + # Give some feedback on cooling process + MMU_LOG MSG='{"Waiting for extruder to completely cool to %d\u00B0C..." % cold_temp}' + {% for temp in ns.cool_points %} + TEMPERATURE_WAIT SENSOR="extruder" MAXIMUM={temp} + MMU_LOG MSG='{"> Nozzle at %d\u00B0C" % temp}' + {% endfor %} + TEMPERATURE_WAIT SENSOR="extruder" MAXIMUM={cold_temp} + + # Re-warm + M107 # Part fan off + MMU_LOG MSG='{"Re-warming extruder to %d\u00B0C" % pull_temp}' + SET_HEATER_TEMPERATURE HEATER="extruder" TARGET={pull_temp} + + # The manual cold-pull + TEMPERATURE_WAIT SENSOR="extruder" MINIMUM={pull_temp - 10} + MMU_LOG MSG="Get ready to pull..." + TEMPERATURE_WAIT SENSOR="extruder" MINIMUM={pull_temp} + MMU_LOG MSG=">>>>> PULL NOW <<<<<" + + # Retract 150 mm at moderate speed (user should assist pull too, especially in bypass)) + _MMU_STEP_MOVE MOTOR="extruder" MOVE=-150 SPEED={pull_speed} ALLOW_BYPASS=1 + + MMU_LOG MSG="Cold pull is successful if you can see the shape of the nozzle at the filament end" + MMU_LOG MSG="If not, try again, perhaps with tweaked temperatures" + + # Heater completely off + SET_HEATER_TEMPERATURE HEATER="extruder" + + +########################################################################### +# Helper macros to display dialog in supporting UIs when MMU pauses +# +[gcode_macro _MMU_ERROR_DIALOG] +description: Helper to display pause dialog +variable_custom_msg: '' # List of additional custom message lines to append in dialog +variable_show_abort: False +gcode: + {% set message = params.MSG|string %} + {% set reason = params.REASON|string %} + RESPOND TYPE=command MSG="action:prompt_begin Happy Hare Error Notice" + RESPOND TYPE=command MSG='{"action:prompt_text %s" % message}' + RESPOND TYPE=command MSG='{"action:prompt_text Reason: %s" % reason}' + {% if not custom_msg == "" %} + {% for line in custom_msg %} + RESPOND TYPE=command MSG='{"action:prompt_text %s" % line}' + {% endfor %} + {% else %} + RESPOND TYPE=command MSG="action:prompt_text After fixing, call RESUME to continue printing (MMU_UNLOCK to restore temperature)" + {% endif %} + RESPOND TYPE=command MSG="action:prompt_button_group_start" + {% if show_abort %} + RESPOND TYPE=command MSG="action:prompt_button ABORT|CANCEL_PRINT|error" + {% endif %} + RESPOND TYPE=command MSG="action:prompt_button UNLOCK|MMU_UNLOCK|secondary" + RESPOND TYPE=command MSG="action:prompt_button RESUME|RESUME|warning" + RESPOND TYPE=command MSG="action:prompt_button_group_end" + RESPOND TYPE=command MSG="action:prompt_show" + {% set custom_msg = "" %} + + +########################################################################### +# Helper for Klippain to reset start/end step "run" trackers +# +[gcode_macro _MMU_RUN_MARKERS] +variable_mmu_start_setup_run: False +variable_mmu_start_check_run: False +variable_mmu_start_load_initial_tool_run: False +variable_mmu_end_run: False +gcode: + SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_setup_run VALUE=False + SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_check_run VALUE=False + SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_load_initial_tool_run VALUE=False + SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_end_run VALUE=False + + +########################################################################### +# Simplified subset of commands just for macro visibility in +# Mainsail/Fluidd UI (until custom HH panel is complete!) +# The __ is a trick because it is not displayed by the UI but allows for +# similar names to the real commands defined by the klipper module +# +[gcode_macro MMU__UNLOAD] +gcode: MMU_UNLOAD + +[gcode_macro MMU__EJECT] +gcode: MMU_EJECT + +[gcode_macro MMU__HOME] +gcode: + {% set tool = params.TOOL|default(0)|int %} + {% set force_unload = params.FORCE_UNLOAD|default(0)|int %} + MMU_HOME TOOL={tool} FORCE_UNLOAD={force_unload} + +[gcode_macro MMU__STATUS] +gcode: MMU_STATUS + +[gcode_macro MMU__MOTORS_OFF] +gcode: MMU_MOTORS_OFF + +[gcode_macro MMU__SERVO] +gcode: + {% set pos = params.POS|default("up")|string %} + MMU_SERVO POS={pos} + +[gcode_macro MMU__SELECT_TOOL] +gcode: + {% set tool = params.TOOL|default(0)|int %} + MMU_SELECT TOOL={tool} + +[gcode_macro MMU__SELECT_BYPASS] +gcode: MMU_SELECT_BYPASS + +[gcode_macro MMU__LOAD_BYPASS] +gcode: MMU_LOAD + +[gcode_macro MMU__RECOVER] +gcode: MMU_RECOVER + +[gcode_macro MMU__PRELOAD] +gcode: + MMU_PRELOAD {rawparams} + +[gcode_macro MMU__CHECK_GATE] +gcode: + {% set gate = params.GATE|default(-1)|int %} + {% set tool = params.GATE|default(-1)|int %} + {% set gates = params.GATE|default('!')|string %} + {% set tools = params.GATE|default('!')|string %} + MMU_CHECK_GATE GATE={gate} TOOL={tool} GATES={gates} TOOLS={tools} + + +########################################################################### +# Macro to query filament pressure sensor (proportional sync sensor) state +# +[gcode_macro MMU_QUERY_PSENSOR] +description: "Show raw and scaled readings for proportional (sync_feedback_analog_*) sensor" +variable_sensor: "filament_proportional" +gcode: + {% set name = params.SENSOR|default(sensor) %} + {% set obj = printer[name] %} + M118 PSENSOR Enabled: {obj.enabled} Value: {obj.value|float|round(3)} Raw Value: {obj.value_raw|float|round(3)} + + +########################################################################### +# Aliases (for backward compatibility) of previously well used commands... +# +[gcode_macro MMU_CHANGE_TOOL_STANDALONE] +description: Convenience macro for inclusion in print_start for initial tool load +gcode: + MMU_CHANGE_TOOL {rawparams} STANDALONE=1 + +[gcode_macro MMU_CHECK_GATES] +description: Alias for updated macro name of MMU_CHECK_GATE +gcode: + MMU_CHECK_GATE ALL=1 + +[gcode_macro MMU_REMAP_TTG] +description: Alias for updated macro name of MMU_TTG_MAP +gcode: + MMU_TTG_MAP {rawparams} + +[gcode_macro MMU_FORM_TIP] +description: Alias for updated macro name of MMU_TEST_FORM_TIP +gcode: + MMU_TEST_FORM_TIP {rawparams} + +# Underscore was removed from these to indicate user can call +[gcode_macro _MMU_PRINT_START] +description: Alias for updated macro name of MMU_PRINT_START +gcode: + MMU_PRINT_START {rawparams} + +[gcode_macro _MMU_PRINT_END] +description: Alias for updated macro name of MMU_PRINT_END +gcode: + MMU_PRINT_END {rawparams} + +[gcode_macro _MMU_UPDATE_HEIGHT] +description: Alias for updated macro name of MMU_UPDATE_HEIGHT +gcode: + MMU_UPDATE_HEIGHT {rawparams} diff --git a/config/base/mmu_state.cfg b/config/base/mmu_state.cfg new file mode 100644 index 000000000000..902192d1137a --- /dev/null +++ b/config/base/mmu_state.cfg @@ -0,0 +1,124 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Callouts for Happy Hare state changes +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# + + +########################################################################### +# Called when when the MMU action status changes +# +# The `ACTION` parameter will contain the current action string +# (also available in `printer.mmu.action` printer variable). +# Also the previous action is available in `OLD_ACTION`. +# +# See Happy Hare README for full list of action strings, but a quick ref is: +# +# Idle|Loading|Unloading|Loading Ext|Exiting Ext|Heating|Checking|Homing|Selecting +# Forming Tip|Cutting Tip|Cutting Filament|Purging +# +[gcode_macro _MMU_ACTION_CHANGED] +description: Called when an action has changed +gcode: + {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} + {% set action = params.ACTION|string %} + {% set old_action = params.OLD_ACTION|string %} + + {% if not vars.user_action_changed_extension == "" %} + {vars.user_action_changed_extension} {rawparams} + {% endif %} + + +########################################################################### +# Called when the MMU print state changes +# +# The `STATE` parameter will contain the current state string +# (also available in `printer.mmu.print_state` printer variable) +# Also the previous action is available in `OLD_STATE`. +# +# See Happy Hare README for full list of state strings and the state transition +# diagram, but a quick ref is: +# +# initialized|ready|started|printing|complete|cancelled|error|pause_locked|paused|standby +# +[gcode_macro _MMU_PRINT_STATE_CHANGED] +description: Called when print state changes +gcode: + {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} + {% set state = params.STATE|string %} + {% set old_state = params.OLD_STATE|string %} + + {% if not vars.user_print_state_changed_extension == "" %} + {vars.user_print_state_changed_extension} {rawparams} + {% endif %} + + +########################################################################### +# Called when an atomic event occurs. Different from ACTION_CHANGE because +# these are not necessarily part of any important state change but rather +# informational +# +# The `EVENT` parameter will contain the event name. Other parameters +# depend on the event type +# +# See Happy Hare README for full list of event strings, but a quick ref is: +# +# Events: +# "restart" Called when Happy Hare starts / restarts +# Parameters: None +# +# "gate_map_changed" Called when the MMU gate_map (containing information +# about the filament type, color, availability and +# spoolId) is updated +# Parameters: GATE The gate that is updated or -1 if all updated +# +# "filament_gripped" Called when MMU servo (if fitted) grips filament +# Parameters: None +# +# "filament_cut" Called when filament is cut +# Parameters: None +# +[gcode_macro _MMU_EVENT] +description: Called when certain MMU actions occur +gcode: + {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} + {% set servo_down_limit = vars.servo_down_limit|default(-1)|int %} + {% set cutter_blade_limit = vars.cutter_blade_limit|default(-1)|int %} + {% set event = params.EVENT|string %} + + {% if event == "restart" %} + MMU_STATS COUNTER=mmu_restarts INCR=1 + + {% set vendor = printer.configfile.config.mmu_machine.mmu_vendor|string|lower %} + {% set version = printer.configfile.config.mmu_machine.mmu_version|string|lower %} + {% if vendor == "ercf" %} + MMU_STATS COUNTER=servo_down LIMIT={servo_down_limit} WARNING="Inspect servo arm for wear/damage" + MMU_STATS COUNTER=cutter_blade LIMIT={cutter_blade_limit} WARNING="Inspect/replace filament cutting blade" + {% elif vendor == "tradrack" %} + MMU_STATS COUNTER=servo_down LIMIT={servo_down_limit} WARNING="Inspect servo mechanism for wear/damage" + MMU_STATS COUNTER=cutter_blade LIMIT={cutter_blade_limit} WARNING="Inspect/replace filament cutting blade" + {% endif %} + + {% elif event == "gate_map_changed" %} + ; + {% elif event == "filament_gripped" %} + MMU_STATS COUNTER=servo_down INCR=1 + {% elif event == "filament_cut" %} + MMU_STATS COUNTER=cutter_blade INCR=1 + {% endif %} + + {% if not vars.user_mmu_event_extension == "" %} + {vars.user_mmu_event_extension} {rawparams} + {% endif %} + diff --git a/config/mmu_vars.cfg b/config/mmu_vars.cfg new file mode 100644 index 000000000000..0bd436a184ba --- /dev/null +++ b/config/mmu_vars.cfg @@ -0,0 +1,8 @@ +# This is the template file for storing Happy Hare state and calibration variables. It is pointed to +# with the [save_variables] block in 'mmu_macro_vars.cfg' +# +# If you want to use an existing "variables" file, then that is fine but make sure you copy the +# "mmu__revision" line to it because Happy Hare will look for this to validate correct setup +# +[Variables] +mmu__revision = 0 diff --git a/config/optional/client_macros.cfg b/config/optional/client_macros.cfg new file mode 100644 index 000000000000..909a464153b0 --- /dev/null +++ b/config/optional/client_macros.cfg @@ -0,0 +1,132 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Portions integrated from mainsail.cfg +# Copyright (C) 2022 Alex Zellner +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Complimentary and functional "client" macros that work with MMU enabled or disabled +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +# These are the recommended PAUSE/RESUME/CANCEL_PRINT macros for use with +# Happy Hare that use the parking logic defined in 'mmu_sequence.cfg' and are +# centrally configured in 'mmu_macro_vars.cfg' +# +# (Technically you can also use your own set but you will likely need to +# modify configuration to avoid double retraction, etc) +# +[gcode_macro PAUSE] +rename_existing: BASE_PAUSE +description: Pause the print and park +gcode: + {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} + + {% if printer.pause_resume.is_paused %} + MMU_LOG MSG="Print is already paused" + {% else %} + _MMU_SAVE_POSITION + BASE_PAUSE + {% if not printer.mmu.enabled %} + _MMU_PARK OPERATION="pause" + {% endif %} + {vars.user_pause_extension|default("")} + {% endif %} + +[gcode_macro RESUME] +rename_existing: BASE_RESUME +description: Resume the print +gcode: + {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} + + {% if not printer.pause_resume.is_paused %} + MMU_LOG MSG="Print is not paused. Resume ignored" + {% else %} + {vars.user_resume_extension|default("")} + {% if not printer.mmu.enabled %} + _MMU_RESTORE_POSITION # This will take the correct "over and down" movement path and unretract + {% endif %} + BASE_RESUME + {% endif %} + +[gcode_macro CANCEL_PRINT] +rename_existing: BASE_CANCEL_PRINT +description: Cancel print +gcode: + {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} + {% set reset_ttg_on_cancel = vars.reset_ttg_on_cancel|default('true')|lower == 'true' %} + {% set unload_tool_on_cancel = vars.unload_tool_on_cancel|default('false')|lower == 'true' %} + + MMU_LOG MSG="Print cancelled!" + {% if not printer.mmu.enabled %} + _MMU_PARK OPERATION="cancel" + {% else %} + {% if unload_tool_on_cancel %} + MMU_LOG MSG="Ejecting filament on print cancel" + MMU_UNLOAD RESTORE=0 + {% endif %} + {% if reset_ttg_on_cancel %} + MMU_TTG_MAP RESET=1 QUIET=1 + {% endif %} + {% endif %} + _MMU_CLEAR_POSITION + TURN_OFF_HEATERS + M107 ; Fan off + SET_PAUSE_NEXT_LAYER ENABLE=0 + SET_PAUSE_AT_LAYER ENABLE=0 LAYER=0 + {vars.user_cancel_extension|default("")} + BASE_CANCEL_PRINT + + +# The following macros are copied from the Mainsail client macros (mainsail.cfg) +# They are integrated here to add the extra functionality into the Happy Hare +# client_macros whilst still retaining centralized and consistent parking logic +# +# Copyright (C) 2022 Alex Zellner + +# Usage: SET_PAUSE_NEXT_LAYER [ENABLE=[0|1]] [MACRO=] +[gcode_macro SET_PAUSE_NEXT_LAYER] +description: Enable a pause if the next layer is reached +gcode: + {% set pause_next_layer = printer['gcode_macro SET_PRINT_STATS_INFO'].pause_next_layer %} + {% set ENABLE = params.ENABLE|default(1)|int != 0 %} + {% set MACRO = params.MACRO|default(pause_next_layer.call, True) %} + SET_GCODE_VARIABLE MACRO=SET_PRINT_STATS_INFO VARIABLE=pause_next_layer VALUE="{{ 'enable': ENABLE, 'call': MACRO }}" + +# Usage: SET_PAUSE_AT_LAYER [ENABLE=[0|1]] [LAYER=] [MACRO=] +[gcode_macro SET_PAUSE_AT_LAYER] +description: Enable/disable a pause if a given layer number is reached +gcode: + {% set pause_at_layer = printer['gcode_macro SET_PRINT_STATS_INFO'].pause_at_layer %} + {% set ENABLE = params.ENABLE|int != 0 if params.ENABLE is defined else params.LAYER is defined %} + {% set LAYER = params.LAYER|default(pause_at_layer.layer)|int %} + {% set MACRO = params.MACRO|default(pause_at_layer.call, True) %} + SET_GCODE_VARIABLE MACRO=SET_PRINT_STATS_INFO VARIABLE=pause_at_layer VALUE="{{ 'enable': ENABLE, 'layer': LAYER, 'call': MACRO }}" + +# Usage: SET_PRINT_STATS_INFO [TOTAL_LAYER=] [CURRENT_LAYER=] +[gcode_macro SET_PRINT_STATS_INFO] +rename_existing: SET_PRINT_STATS_INFO_BASE +description: Overwrite, to get pause_next_layer and pause_at_layer feature +variable_pause_next_layer: { 'enable': False, 'call': "PAUSE" } +variable_pause_at_layer : { 'enable': False, 'layer': 0, 'call': "PAUSE" } +gcode: + {% if pause_next_layer.enable %} + MMU_LOG MSG='{"%s, forced by pause_next_layer" % pause_next_layer.call}' + {pause_next_layer.call} ; execute the given gcode to pause, should be either M600 or PAUSE + SET_PAUSE_NEXT_LAYER ENABLE=0 + {% elif pause_at_layer.enable and params.CURRENT_LAYER is defined and params.CURRENT_LAYER|int == pause_at_layer.layer %} + MMU_LOG MSG='{"%s, forced by pause_at_layer [%d]" % (pause_at_layer.call, pause_at_layer.layer)}' + {pause_at_layer.call} ; execute the given gcode to pause, should be either M600 or PAUSE + SET_PAUSE_AT_LAYER ENABLE=0 + {% endif %} + SET_PRINT_STATS_INFO_BASE {rawparams} diff --git a/config/optional/mmu_menu.cfg b/config/optional/mmu_menu.cfg new file mode 100644 index 000000000000..2c97f54aa569 --- /dev/null +++ b/config/optional/mmu_menu.cfg @@ -0,0 +1,146 @@ +######################################################################################################################## +# Happy Hare MMU Software +# Supporting macros +# +# THIS FILE IS READ ONLY +# +# Copyright (C) 2022 moggieuk#6538 (discord) moggieuk@hotmail.com +# This file may be distributed under the terms of the GNU GPLv3 license. +# +# Goal: Happy Hare MMU MENU designed for LCD Mini12864 screen +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# +[menu __main __MMU] +enable: {printer.mmu.enabled} +type: list +name: MMU +index: 6 + +[menu __main __MMU __HOME] +type: command +name: Home MMU +index: 1 +gcode: MMU_HOME + +[menu __main __MMU __SERVO_UP] +type: command +name: Servo up +index: 2 +gcode: MMU_SERVO POS=up + +[menu __main __MMU __SERVO_MOVE] +type: command +name: Servo move +index: 3 +gcode: MMU_SERVO POS=move + +[menu __main __MMU __SERVO_DOWN] +type: command +name: Servo down +index: 4 +gcode: MMU_SERVO POS=down + +[menu __main __MMU __CHANGE_TOOL] +type: input +name: Change Tool: {'%2d' % (menu.input|int)} +input: 0 +input_min: 0 +input_max: 8 +input_step: 1 +index: 5 +gcode: + MMU_CHANGE_TOOL STANDALONE=1 TOOL={menu.input|int} + +[menu __main __MMU __SELECT_TOOL] +type: input +name: Select Tool: {'%2d' % (menu.input|int)} +input: 0 +input_min: 0 +input_max: 8 +input_step: 1 +index: 6 +gcode: + MMU_SELECT TOOL={menu.input|int} + +[menu __main __MMU __PRELOAD_TOOL] +type: input +name: Preload Tool: {'%1d' % (menu.input|int)} +input: 0 +input_min: 0 +input_max: 8 +input_step: 1 +index: 7 +gcode: + MMU_PRELOAD GATE={menu.input|int} + +[menu __main __MMU __EJECT] +type: command +name: Eject +index: 8 +gcode: MMU_EJECT + +[menu __main __MMU __RECOVER] +type: command +name: Recover +index: 9 +gcode: MMU_RECOVER + +[menu __main __MMU __SELECT_BYPASS] +enable: {not printer.idle_timeout.state == "Printing"} +type: command +name: Select bypass +index: 10 +gcode: MMU_SELECT_BYPASS + +[menu __main __MMU __LOAD_BYPASS] +enable: {not printer.idle_timeout.state == "Printing" and printer.mmu.gate == -2} +type: command +name: Load bypass +index: 11 +gcode: MMU_LOAD + +[menu __main __MMU __UNLOAD_BYPASS] +enable: {not printer.idle_timeout.state == "Printing" and printer.mmu.gate == -2} +type: command +name: Unload bypass +index: 13 +gcode: MMU_EJECT + +[menu __main __MMU __clogdetection] +type: input +name: Clog detect: {'%2d' % (menu.input|int)} +input: 0 +input_min: 0 +input_max: 2 +input_step: 1 +index: 14 +gcode: + MMU_TEST_CONFIG enable_clog_detection={menu.input|int} + +[menu __main __MMU __endlessspool] +type: input +name: Endl. spool: {'%2d' % (menu.input|int)} +input: 0 +input_min: 0 +input_max: 1 +input_step: 1 +index: 15 +gcode: + MMU_TEST_CONFIG enable_endless_spool={menu.input|int} + +[menu __main __MMU __STATUS] +type: command +name: Show Status +index: 16 +gcode: MMU_STATUS + +[menu __main __MMU __MOTORS_OFF] +type: command +name: Motors off +index: 17 +gcode: MMU_MOTORS_OFF + diff --git a/extras/.pylintrc b/extras/.pylintrc new file mode 100644 index 000000000000..d5841953ca85 --- /dev/null +++ b/extras/.pylintrc @@ -0,0 +1,12 @@ +[FORMAT] +max-line-length=400 + +[MESSAGES CONTROL] +disable=attribute-defined-outside-init, consider-using-f-string, too-many-lines, fixme, multiple-imports, invalid-name, multiple-statements, missing-function-docstring, unused-import, too-many-public-methods, too-many-return-statements, too-many-branches, too-many-statements, too-many-nested-blocks, missing-module-docstring, missing-class-docstring, too-many-instance-attributes, raise-missing-from, bare-except, broad-except, no-else-return, too-many-locals, too-many-arguments, no-else-raise, unused-argument, lost-exception, logging-not-lazy, super-with-arguments, too-few-public-methods, unnecessary-lambda-assignment, useless-object-inheritance + +[DESIGN] +# Maximum number of boolean expressions in an if statement +max-bool-expr=6 + +[MASTER] +ignore=mmu_test.py diff --git a/extras/mmu/__init__.py b/extras/mmu/__init__.py new file mode 100644 index 000000000000..14f95c9b5507 --- /dev/null +++ b/extras/mmu/__init__.py @@ -0,0 +1,21 @@ +# Happy Hare MMU Software +# Main module wrapper +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Firmware to control any Klipper based MMU +# +# Note that code is written in a system to support Python v2 given the widespread use in +# the klipper community. Hopefully this will change soon. +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +from .mmu import Mmu + +def load_config(config): + return Mmu(config) diff --git a/extras/mmu/mmu.py b/extras/mmu/mmu.py new file mode 100644 index 000000000000..485a0deffc29 --- /dev/null +++ b/extras/mmu/mmu.py @@ -0,0 +1,9081 @@ +# Happy Hare MMU Software +# Main module +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Main control class for any Klipper based MMU (includes filament driver/gear control) +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import gc, sys, ast, random, logging, time, contextlib, math, os.path, re, unicodedata, traceback +from statistics import stdev + +# Klipper imports +import chelper +from ..homing import Homing, HomingMove +from ..tmc import TMCCommandHelper + +# Happy Hare imports +from .. import mmu_machine +from ..mmu_machine import MmuToolHead +from ..mmu_sensors import MmuRunoutHelper + +# MMU subcomponent clases +from .mmu_shared import * +from .mmu_logger import MmuLogger +from .mmu_selector import * +from .mmu_test import MmuTest +from .mmu_utils import DebugStepperMovement, PurgeVolCalculator +from .mmu_sensor_manager import MmuSensorManager +from .mmu_led_manager import MmuLedManager +from .mmu_sync_feedback_manager import MmuSyncFeedbackManager +from .mmu_calibration_manager import MmuCalibrationManager +from .mmu_environment_manager import MmuEnvironmentManager + + +# Main klipper module +class Mmu: + VERSION = 3.42 # When this is revved, Happy Hare will instruct users to re-run ./install.sh. Sync with install.sh! + + BOOT_DELAY = 2.5 # Delay before running bootup tasks + + # Calibration steps + CALIBRATED_GEAR_0 = 0b00001 # Specifically rotation_distance for gate 0 + CALIBRATED_ENCODER = 0b00010 + CALIBRATED_SELECTOR = 0b00100 # Defaults true with VirtualSelector + CALIBRATED_BOWDENS = 0b01000 # Bowden length for all gates + CALIBRATED_GEAR_RDS = 0b10000 + CALIBRATED_ESSENTIAL = 0b01111 + CALIBRATED_ALL = 0b11111 + + TOOL_GATE_UNKNOWN = -1 + TOOL_GATE_BYPASS = -2 + + GATE_UNKNOWN = -1 + GATE_EMPTY = 0 + GATE_AVAILABLE = 1 # Available to load from either buffer or spool + GATE_AVAILABLE_FROM_BUFFER = 2 + + FILAMENT_POS_UNKNOWN = -1 + FILAMENT_POS_UNLOADED = 0 # Parked in gate + FILAMENT_POS_HOMED_GATE = 1 # Homed at either gate or gear sensor (currently assumed mutually exclusive sensors) + FILAMENT_POS_START_BOWDEN = 2 # Point of fast load portion + FILAMENT_POS_IN_BOWDEN = 3 # Some unknown position in the bowden + FILAMENT_POS_END_BOWDEN = 4 # End of fast load portion + FILAMENT_POS_HOMED_ENTRY = 5 # Homed at entry sensor + FILAMENT_POS_HOMED_EXTRUDER = 6 # Collision homing case at extruder gear entry + FILAMENT_POS_EXTRUDER_ENTRY = 7 # Past extruder gear entry + FILAMENT_POS_HOMED_TS = 8 # Homed at toolhead sensor + FILAMENT_POS_IN_EXTRUDER = 9 # In extruder past toolhead sensor + FILAMENT_POS_LOADED = 10 # Homed to nozzle + + DIRECTION_LOAD = 1 + DIRECTION_UNKNOWN = 0 + DIRECTION_UNLOAD = -1 + + FORM_TIP_NONE = 0 # Skip tip forming + FORM_TIP_SLICER = 1 # Slicer forms tips + FORM_TIP_STANDALONE = 2 # Happy Hare forms tips + + PURGE_NONE = 0 # Skip purging after load + PURGE_SLICER = 1 # Slicer purges on wipetower + PURGE_STANDALONE = 2 # Happy Hare purges + + ACTION_IDLE = 0 + ACTION_LOADING = 1 + ACTION_LOADING_EXTRUDER = 2 + ACTION_UNLOADING = 3 + ACTION_UNLOADING_EXTRUDER = 4 + ACTION_FORMING_TIP = 5 + ACTION_HEATING = 6 + ACTION_CHECKING = 7 + ACTION_HOMING = 8 + ACTION_SELECTING = 9 + ACTION_CUTTING_TIP = 10 # Cutting at toolhead e.g. _MMU_CUT_TIP macro + ACTION_CUTTING_FILAMENT = 11 # Cutting at MMU e.g. EREC cutting macro + ACTION_PURGING = 12 # Non slicer purging e.g. when running blobifier + + MACRO_EVENT_RESTART = "restart" # Params: None + MACRO_EVENT_GATE_MAP_CHANGED = "gate_map_changed" # Params: GATE changed or GATE=-1 for all + MACRO_EVENT_FILAMENT_GRIPPED = "filament_gripped" # Params: None + + # Standard sensor and endstop or pseudo endstop names + SENSOR_ENCODER = "encoder" # Fake Gate endstop + SENSOR_GATE = "mmu_gate" # Gate + SENSOR_GEAR_PREFIX = "mmu_gear" + + SENSOR_EXTRUDER_NONE = "none" # Fake Extruder endstop aka don't attempt home + SENSOR_EXTRUDER_COLLISION = "collision" # Fake Extruder endstop + SENSOR_EXTRUDER_ENTRY = "extruder" # Extruder entry sensor + SENSOR_GEAR_TOUCH = "mmu_gear_touch" # Stallguard based detection + + SENSOR_COMPRESSION = "filament_compression" # Filament sync-feedback compression detection + SENSOR_TENSION = "filament_tension" # Filament sync-feedback tension detection + SENSOR_PROPORTIONAL = "filament_proportional" # Proportional sync-feedback sensor + + SENSOR_TOOLHEAD = "toolhead" + SENSOR_EXTRUDER_TOUCH = "mmu_ext_touch" + + SENSOR_SELECTOR_TOUCH = "mmu_sel_touch" # For LinearSelector and LinearServoSelector + SENSOR_SELECTOR_HOME = "mmu_sel_home" # For LinearSelector and LinearServoSelector + SENSOR_PRE_GATE_PREFIX = "mmu_pre_gate" + + EXTRUDER_ENDSTOPS = [SENSOR_EXTRUDER_COLLISION, SENSOR_GEAR_TOUCH, SENSOR_EXTRUDER_ENTRY, SENSOR_EXTRUDER_NONE, SENSOR_COMPRESSION] + GATE_ENDSTOPS = [SENSOR_GATE, SENSOR_ENCODER, SENSOR_GEAR_PREFIX, SENSOR_EXTRUDER_ENTRY] + + # Statistics output types + GATE_STATS_STRING = "string" + GATE_STATS_PERCENTAGE = "percentage" + GATE_STATS_EMOTICON = "emoticon" + + GATE_STATS_TYPES = [GATE_STATS_STRING, GATE_STATS_PERCENTAGE, GATE_STATS_EMOTICON] + + # Levels of logging + LOG_ESSENTIAL = 0 + LOG_INFO = 1 + LOG_DEBUG = 2 + LOG_TRACE = 3 + LOG_STEPPER = 4 + LOG_LEVELS = ['ESSENTAL', 'INFO', 'DEBUG', 'TRACE', 'STEPPER'] + + # States of espooler motor + ESPOOLER_OFF = 'off' + ESPOOLER_REWIND = 'rewind' + ESPOOLER_ASSIST = 'assist' + ESPOOLER_PRINT = 'print' # Special in-print assist state for active gate + ESPOOLER_OPERATIONS = [ESPOOLER_OFF, ESPOOLER_REWIND, ESPOOLER_ASSIST, ESPOOLER_PRINT] + + # Name used to save gcode state + TOOLHEAD_POSITION_STATE = 'MMU_state' + + # Filament "grip" states + FILAMENT_UNKNOWN_STATE = -1 + FILAMENT_RELEASE_STATE = 0 + FILAMENT_DRIVE_STATE = 1 + FILAMENT_HOLD_STATE = 2 + + # mmu_vars.cfg variables + VARS_MMU_REVISION = "mmu__revision" + VARS_MMU_CALIB_CLOG_LENGTH = "mmu_calibration_clog_length" + VARS_MMU_ENABLE_ENDLESS_SPOOL = "mmu_state_enable_endless_spool" + VARS_MMU_ENDLESS_SPOOL_GROUPS = "mmu_state_endless_spool_groups" + VARS_MMU_TOOL_TO_GATE_MAP = "mmu_state_tool_to_gate_map" + VARS_MMU_GATE_STATUS = "mmu_state_gate_status" + VARS_MMU_GATE_MATERIAL = "mmu_state_gate_material" + VARS_MMU_GATE_COLOR = "mmu_state_gate_color" + VARS_MMU_GATE_FILAMENT_NAME = "mmu_state_gate_filament_name" + VARS_MMU_GATE_TEMPERATURE = "mmu_state_gate_temperature" + VARS_MMU_GATE_SPOOL_ID = "mmu_state_gate_spool_id" + VARS_MMU_GATE_SPEED_OVERRIDE = "mmu_state_gate_speed_override" + VARS_MMU_GATE_SELECTED = "mmu_state_gate_selected" + VARS_MMU_TOOL_SELECTED = "mmu_state_tool_selected" + VARS_MMU_LAST_TOOL = "mmu_state_last_tool" + VARS_MMU_FILAMENT_POS = "mmu_state_filament_pos" + VARS_MMU_FILAMENT_REMAINING = "mmu_state_filament_remaining" + VARS_MMU_FILAMENT_REMAINING_COLOR = "mmu_state_filament_remaining_color" + VARS_MMU_GATE_STATISTICS_PREFIX = "mmu_statistics_gate_" + VARS_MMU_SWAP_STATISTICS = "mmu_statistics_swaps" + VARS_MMU_COUNTERS = "mmu_statistics_counters" + + # Calibration data + VARS_MMU_ENCODER_RESOLUTION = "mmu_encoder_resolution" + VARS_MMU_GEAR_ROTATION_DISTANCES = "mmu_gear_rotation_distances" + VARS_MMU_CALIB_BOWDEN_LENGTHS = "mmu_calibration_bowden_lengths" # Per-gate calibrated bowden lengths + VARS_MMU_CALIB_BOWDEN_HOME = "mmu_calibration_bowden_home" # Was encoder, gate or gear sensor used as reference point + VARS_MMU_CALIB_BOWDEN_LENGTH = "mmu_calibration_bowden_length" # DEPRECATED (for upgrade only) + VARS_MMU_GEAR_ROTATION_DISTANCE = "mmu_gear_rotation_distance" # DEPRECATED (for upgrade only) + VARS_MMU_CALIB_PREFIX = "mmu_calibration_" # DEPRECATED (for upgrade only) + + # Mainsail/Fluid visualization of extruder colors and other attributes + T_MACRO_COLOR_ALLGATES = 'allgates' # Color from gate map (all tools). Will add spool_id if spoolman is enabled + T_MACRO_COLOR_GATEMAP = 'gatemap' # As per gatemap but hide empty tools. Will add spool_id if spoolman is enabled + T_MACRO_COLOR_SLICER = 'slicer' # Color from slicer tool map. Will add spool_id if spoolman is enabled + T_MACRO_COLOR_OFF = 'off' # Turn off color and spool_id association + T_MACRO_COLOR_OPTIONS = [T_MACRO_COLOR_GATEMAP, T_MACRO_COLOR_SLICER, T_MACRO_COLOR_ALLGATES, T_MACRO_COLOR_OFF] + + # Spoolman integration - modes of operation + SPOOLMAN_OFF = 'off' # Spoolman disabled + SPOOLMAN_READONLY = 'readonly' # Get filament attributes only + SPOOLMAN_PUSH = 'push' # Local gatemap is the source or truth + SPOOLMAN_PULL = 'pull' # Spoolman db is the source of truth + SPOOLMAN_OPTIONS = [SPOOLMAN_OFF, SPOOLMAN_READONLY, SPOOLMAN_PUSH, SPOOLMAN_PULL] + SPOOLMAN_CONFIG_ERROR = "Moonraker/spoolman may not be configured (check moonraker.log)" + + # Automap strategies + AUTOMAP_NONE = 'none' + AUTOMAP_FILAMENT_NAME = 'filament_name' + AUTOMAP_SPOOL_ID = 'spool_id' + AUTOMAP_MATERIAL = 'material' + AUTOMAP_CLOSEST_COLOR = 'closest_color' + AUTOMAP_COLOR = 'color' + AUTOMAP_OPTIONS = [AUTOMAP_NONE, AUTOMAP_FILAMENT_NAME, AUTOMAP_SPOOL_ID, AUTOMAP_MATERIAL, AUTOMAP_CLOSEST_COLOR, AUTOMAP_COLOR] + + EMPTY_GATE_STATS_ENTRY = {'pauses': 0, 'loads': 0, 'load_distance': 0.0, 'load_delta': 0.0, 'unloads': 0, 'unload_distance': 0.0, 'unload_delta': 0.0, 'load_failures': 0, 'unload_failures': 0, 'quality': -1.} + + W3C_COLORS = [('aliceblue','#F0F8FF'), ('antiquewhite','#FAEBD7'), ('aqua','#00FFFF'), ('aquamarine','#7FFFD4'), ('azure','#F0FFFF'), ('beige','#F5F5DC'), + ('bisque','#FFE4C4'), ('black','#000000'), ('blanchedalmond','#FFEBCD'), ('blue','#0000FF'), ('blueviolet','#8A2BE2'), ('brown','#A52A2A'), + ('burlywood','#DEB887'), ('cadetblue','#5F9EA0'), ('chartreuse','#7FFF00'), ('chocolate','#D2691E'), ('coral','#FF7F50'), + ('cornflowerblue','#6495ED'), ('cornsilk','#FFF8DC'), ('crimson','#DC143C'), ('cyan','#00FFFF'), ('darkblue','#00008B'), ('darkcyan','#008B8B'), + ('darkgoldenrod','#B8860B'), ('darkgray','#A9A9A9'), ('darkgreen','#006400'), ('darkgrey','#A9A9A9'), ('darkkhaki','#BDB76B'), + ('darkmagenta','#8B008B'), ('darkolivegreen','#556B2F'), ('darkorange','#FF8C00'), ('darkorchid','#9932CC'), ('darkred','#8B0000'), + ('darksalmon','#E9967A'), ('darkseagreen','#8FBC8F'), ('darkslateblue','#483D8B'), ('darkslategray','#2F4F4F'), ('darkslategrey','#2F4F4F'), + ('darkturquoise','#00CED1'), ('darkviolet','#9400D3'), ('deeppink','#FF1493'), ('deepskyblue','#00BFFF'), ('dimgray','#696969'), + ('dimgrey','#696969'), ('dodgerblue','#1E90FF'), ('firebrick','#B22222'), ('floralwhite','#FFFAF0'), ('forestgreen','#228B22'), + ('fuchsia','#FF00FF'), ('gainsboro','#DCDCDC'), ('ghostwhite','#F8F8FF'), ('gold','#FFD700'), ('goldenrod','#DAA520'), ('gray','#808080'), + ('green','#008000'), ('greenyellow','#ADFF2F'), ('grey','#808080'), ('honeydew','#F0FFF0'), ('hotpink','#FF69B4'), ('indianred','#CD5C5C'), + ('indigo','#4B0082'), ('ivory','#FFFFF0'), ('khaki','#F0E68C'), ('lavender','#E6E6FA'), ('lavenderblush','#FFF0F5'), ('lawngreen','#7CFC00'), + ('lemonchiffon','#FFFACD'), ('lightblue','#ADD8E6'), ('lightcoral','#F08080'), ('lightcyan','#E0FFFF'), ('lightgoldenrodyellow','#FAFAD2'), + ('lightgray','#D3D3D3'), ('lightgreen','#90EE90'), ('lightgrey','#D3D3D3'), ('lightpink','#FFB6C1'), ('lightsalmon','#FFA07A'), + ('lightseagreen','#20B2AA'), ('lightskyblue','#87CEFA'), ('lightslategray','#778899'), ('lightslategrey','#778899'), + ('lightsteelblue','#B0C4DE'), ('lightyellow','#FFFFE0'), ('lime','#00FF00'), ('limegreen','#32CD32'), ('linen','#FAF0E6'), + ('magenta','#FF00FF'), ('maroon','#800000'), ('mediumaquamarine','#66CDAA'), ('mediumblue','#0000CD'), ('mediumorchid','#BA55D3'), + ('mediumpurple','#9370DB'), ('mediumseagreen','#3CB371'), ('mediumslateblue','#7B68EE'), ('mediumspringgreen','#00FA9A'), + ('mediumturquoise','#48D1CC'), ('mediumvioletred','#C71585'), ('midnightblue','#191970'), ('mintcream','#F5FFFA'), ('mistyrose','#FFE4E1'), + ('moccasin','#FFE4B5'), ('navajowhite','#FFDEAD'), ('navy','#000080'), ('oldlace','#FDF5E6'), ('olive','#808000'), + ('olivedrab','#6B8E23'), ('orange','#FFA500'), ('orangered','#FF4500'), ('orchid','#DA70D6'), ('palegoldenrod','#EEE8AA'), + ('palegreen','#98FB98'), ('paleturquoise','#AFEEEE'), ('palevioletred','#DB7093'), ('papayawhip','#FFEFD5'), ('peachpuff','#FFDAB9'), + ('peru','#CD853F'), ('pink','#FFC0CB'), ('plum','#DDA0DD'), ('powderblue','#B0E0E6'), ('purple','#800080'), ('red','#FF0000'), + ('rosybrown','#BC8F8F'), ('royalblue','#4169E1'), ('saddlebrown','#8B4513'), ('salmon','#FA8072'), ('sandybrown','#F4A460'), + ('seagreen','#2E8B57'), ('seashell','#FFF5EE'), ('sienna','#A0522D'), ('silver','#C0C0C0'), ('skyblue','#87CEEB'), ('slateblue','#6A5ACD'), + ('slategray','#708090'), ('slategrey','#708090'), ('snow','#FFFAFA'), ('springgreen','#00FF7F'), ('steelblue','#4682B4'), + ('tan','#D2B48C'), ('teal','#008080'), ('thistle','#D8BFD8'), ('tomato','#FF6347'), ('turquoise','#40E0D0'), ('violet','#EE82EE'), + ('wheat','#F5DEB3'), ('white','#FFFFFF'), ('whitesmoke','#F5F5F5'), ('yellow','#FFFF00'), ('yellowgreen','#9ACD32')] + + UPGRADE_REMINDER = "Sorry but Happy Hare requires you to re-run this to complete the update:\ncd ~/Happy-Hare\n./install.sh\nMore details: https://github.com/moggieuk/Happy-Hare/wiki/Upgrade-Notice" + + def __init__(self, config): + self.config = config + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object('gcode') + self.gcode_move = self.printer.load_object(config, 'gcode_move') + + self.managers = [] # List of mmu manager classes to encapsulate functionality. Managers add themselves + self.mmu_machine = self.printer.lookup_object("mmu_machine") + self.num_gates = self.mmu_machine.num_gates + self.calibration_status = 0b0 + self.w3c_colors = dict(self.W3C_COLORS) + self.filament_remaining = 0. + self._last_tool = self._next_tool = self.TOOL_GATE_UNKNOWN + self._next_gate = None + self.toolchange_retract = 0. # Set from mmu_macro_vars + self._can_write_variables = False + self.toolchange_purge_volume = 0. + self.mmu_logger = None # Setup on connect + self._standalone_sync = False # Used to indicate synced extruder intention whilst out of print + self._suppress_release_grip = False # Used to suppress the relaxing of grip on recursive calls to prevent servo flutter + self.bowden_start_pos = None # If set then we can measure bowden progress + self.has_blobifier = False # Post load blobbling macro (like BLOBIFIER) + self.has_mmu_cutter = False # Post unload cutting macro (like EREC) + self.has_toolhead_cutter = False # Form tip cutting macro (like _MMU_CUT_TIP) + self._is_running_test = False # True while running QA or soak tests + self.slicer_tool_map = None + + # Event handlers + self.printer.register_event_handler('klippy:connect', self.handle_connect) + self.printer.register_event_handler("klippy:disconnect", self.handle_disconnect) + self.printer.register_event_handler("klippy:ready", self.handle_ready) + + # Instruct users to re-run ./install.sh if version number changes + self.config_version = config.getfloat('happy_hare_version', 2.2) # v2.2 was the last release before versioning + if self.config_version is not None and self.config_version < self.VERSION: + raise self.config.error("Looks like you upgraded (v%s -> v%s)?\n%s" % (self.config_version, self.VERSION, self.UPGRADE_REMINDER)) + + # Detect Kalico (Danger Klipper) installation + self.kalico = bool(self.printer.lookup_object('danger_options', False)) + + # Setup remaining hardware like MMU toolhead -------------------------------------------------------- + # We setup MMU hardware during configuration since some hardware like endstop requires + # configuration during the MCU config phase, which happens before klipper connection + # This assumes that the hardware definition appears before the '[mmu]' section. + # The default recommended install will guarantee this order + self._setup_mmu_hardware(config) + + # Read user configuration --------------------------------------------------------------------------- + # + # Printer interaction config + self.extruder_name = config.get('extruder', 'extruder') + self.timeout_pause = config.getint('timeout_pause', 72000, minval=120) + self.default_idle_timeout = config.getint('default_idle_timeout', -1, minval=120) + self.pending_spool_id_timeout = config.getint('pending_spool_id_timeout', default=20, minval=-1) # Not currently exposed + self.disable_heater = config.getint('disable_heater', 600, minval=60) + self.default_extruder_temp = config.getfloat('default_extruder_temp', 200., minval=0.) + self.extruder_temp_variance = config.getfloat('extruder_temp_variance', 2., minval=1.) + self.gcode_load_sequence = config.getint('gcode_load_sequence', 0) + self.gcode_unload_sequence = config.getint('gcode_unload_sequence', 0) + self.slicer_tip_park_pos = config.getfloat('slicer_tip_park_pos', 0., minval=0.) + self.force_form_tip_standalone = config.getint('force_form_tip_standalone', 0, minval=0, maxval=1) + self.force_purge_standalone = config.getint('force_purge_standalone', 0, minval=0, maxval=1) + self.strict_filament_recovery = config.getint('strict_filament_recovery', 0, minval=0, maxval=1) + self.filament_recovery_on_pause = config.getint('filament_recovery_on_pause', 1, minval=0, maxval=1) + self.retry_tool_change_on_error = config.getint('retry_tool_change_on_error', 0, minval=0, maxval=1) + self.print_start_detection = config.getint('print_start_detection', 1, minval=0, maxval=1) + self.startup_home_if_unloaded = config.getint('startup_home_if_unloaded', 0, minval=0, maxval=1) + self.startup_reset_ttg_map = config.getint('startup_reset_ttg_map', 0, minval=0, maxval=1) + self.show_error_dialog = config.getint('show_error_dialog', 1, minval=0, maxval=1) + + # Automatic calibration / tuning options + self.autocal_selector = config.getint('autocal_selector', 0, minval=0, maxval=1) # Not exposed TODO placeholder for implementation + self.skip_cal_rotation_distance = config.getint('skip_cal_rotation_distance', 0, minval=0, maxval=1) + self.autotune_rotation_distance = config.getint('autotune_rotation_distance', 0, minval=0, maxval=1) + self.autocal_bowden_length = config.getint('autocal_bowden_length', 1, minval=0, maxval=1) + self.autotune_bowden_length = config.getint('autotune_bowden_length', 0, minval=0, maxval=1) + self.skip_cal_encoder = config.getint('skip_cal_encoder', 0, minval=0, maxval=1) + self.autotune_encoder = config.getint('autotune_encoder', 0, minval=0, maxval=1) # Not exposed TODO placeholder for implementation + + # Internal macro overrides + self.pause_macro = config.get('pause_macro', 'PAUSE') + self.action_changed_macro = config.get('action_changed_macro', '_MMU_ACTION_CHANGED') + self.print_state_changed_macro = config.get('print_state_changed_macro', '_MMU_PRINT_STATE_CHANGED') + self.mmu_event_macro = config.get('mmu_event_macro', '_MMU_EVENT') + self.form_tip_macro = config.get('form_tip_macro', '_MMU_FORM_TIP').replace("'", "") + self.purge_macro = config.get('purge_macro', '').replace("'", "") + self.pre_unload_macro = config.get('pre_unload_macro', '_MMU_PRE_UNLOAD').replace("'", "") + self.post_form_tip_macro = config.get('post_form_tip_macro', '_MMU_POST_FORM_TIP').replace("'", "") + self.post_unload_macro = config.get('post_unload_macro', '_MMU_POST_UNLOAD').replace("'", "") + self.pre_load_macro = config.get('pre_load_macro', '_MMU_PRE_LOAD').replace("'", "") + self.post_load_macro = config.get('post_load_macro', '_MMU_POST_LOAD_MACRO').replace("'", "") + self.unload_sequence_macro = config.get('unload_sequence_macro', '_MMU_UNLOAD_SEQUENCE').replace("'", "") + self.load_sequence_macro = config.get('load_sequence_macro', '_MMU_LOAD_SEQUENCE').replace("'", "") + + # These macros are not currently exposed but provide future flexability + self.error_dialog_macro = config.get('error_dialog_macro', '_MMU_ERROR_DIALOG') # Not exposed + self.error_macro = config.get('error_macro', '_MMU_ERROR') # Not exposed + self.toolhead_homing_macro = config.get('toolhead_homing_macro', '_MMU_AUTO_HOME') # Not exposed + self.park_macro = config.get('park_macro', '_MMU_PARK') # Not exposed + self.save_position_macro = config.get('save_position_macro', '_MMU_SAVE_POSITION') # Not exposed + self.restore_position_macro = config.get('restore_position_macro', '_MMU_RESTORE_POSITION') # Not exposed + self.clear_position_macro = config.get('clear_position_macro', '_MMU_CLEAR_POSITION') # Not exposed + + # User default (reset state) gate map and TTG map + self.default_ttg_map = list(config.getintlist('tool_to_gate_map', [])) + self.default_gate_status = list(config.getintlist('gate_status', [])) + self.default_gate_filament_name = list(config.getlist('gate_filament_name', [])) + self.default_gate_material = list(config.getlist('gate_material', [])) + self.default_gate_color = list(config.getlist('gate_color', [])) + self.default_gate_temperature = list(config.getintlist('gate_temperature', [])) + self.default_gate_spool_id = list(config.getintlist('gate_spool_id', [])) + self.default_gate_speed_override = list(config.getintlist('gate_speed_override', [])) + + # Configuration for gate loading and unloading + self.gate_homing_endstop = config.getchoice('gate_homing_endstop', {o: o for o in self.GATE_ENDSTOPS}, self.SENSOR_ENCODER) + self.gate_endstop_to_encoder = config.getfloat('gate_endstop_to_encoder', 0., minval=0.) + self.gate_unload_buffer = config.getfloat('gate_unload_buffer', 30., minval=0.) # How far to short bowden move to avoid overshooting the gate + self.gate_homing_max = config.getfloat('gate_homing_max', 2 * self.gate_unload_buffer, minval=self.gate_unload_buffer) + self.gate_preload_homing_max = config.getfloat('gate_preload_homing_max', self.gate_homing_max) + self.gate_parking_distance = config.getfloat('gate_parking_distance', 23.) # Can be +ve or -ve + self.gate_preload_parking_distance = config.getfloat('gate_preload_parking_distance', -10.) # Can be +ve or -ve + self.gate_load_retries = config.getint('gate_load_retries', 1, minval=1, maxval=5) + self.gate_autoload = config.getint('gate_autoload', 1, minval=0, maxval=1) + self.gate_final_eject_distance = config.getfloat('gate_final_eject_distance', 0) + self.bypass_autoload = config.getint('bypass_autoload', 1, minval=0, maxval=1) + self.encoder_dwell = config.getfloat('encoder_dwell', 0.1, minval=0., maxval=2.) # Not exposed + self.encoder_move_step_size = config.getfloat('encoder_move_step_size', 15., minval=5., maxval=25.) # Not exposed + + # Configuration for (fast) bowden move + self.bowden_homing_max = config.getfloat('bowden_homing_max', 2000., minval=100.) + self.bowden_apply_correction = config.getint('bowden_apply_correction', 0, minval=0, maxval=1) + self.bowden_allowable_load_delta = config.getfloat('bowden_allowable_load_delta', 10., minval=1.) + self.bowden_allowable_unload_delta = config.getfloat('bowden_allowable_unload_delta', self.bowden_allowable_load_delta, minval=1.) + self.bowden_move_error_tolerance = config.getfloat('bowden_move_error_tolerance', 60, minval=0, maxval=100) # Percentage of delta of move that results in error + self.bowden_pre_unload_test = config.getint('bowden_pre_unload_test', 0, minval=0, maxval=1) # Check for bowden movement before full pull + self.bowden_pre_unload_error_tolerance = config.getfloat('bowden_pre_unload_error_tolerance', 100, minval=0, maxval=100) # Allowable delta movement % before error + + # Configuration for extruder and toolhead homing + self.extruder_force_homing = config.getint('extruder_force_homing', 0, minval=0, maxval=1) + self.extruder_homing_endstop = config.getchoice('extruder_homing_endstop', {o: o for o in self.EXTRUDER_ENDSTOPS}, self.SENSOR_EXTRUDER_NONE) + self.extruder_homing_max = config.getfloat('extruder_homing_max', 50., above=10.) # Extruder homing max + self.extruder_homing_buffer = config.getfloat('extruder_homing_buffer', 30., minval=0.) # How far to short bowden load move to avoid overshooting + self.extruder_collision_homing_step = config.getint('extruder_collision_homing_step', 3, minval=2, maxval=5) + self.toolhead_homing_max = config.getfloat('toolhead_homing_max', 20., minval=0.) # Toolhead sensor homing max + self.toolhead_extruder_to_nozzle = config.getfloat('toolhead_extruder_to_nozzle', 0., minval=5.) # For "sensorless" + self.toolhead_sensor_to_nozzle = config.getfloat('toolhead_sensor_to_nozzle', 0., minval=1.) # For toolhead sensor + self.toolhead_entry_to_extruder = config.getfloat('toolhead_entry_to_extruder', 0., minval=0.) # For extruder (entry) sensor + self.toolhead_residual_filament = config.getfloat('toolhead_residual_filament', 0., minval=0., maxval=50.) # +ve value = reduction of load length + self.toolhead_ooze_reduction = config.getfloat('toolhead_ooze_reduction', 0., minval=-5., maxval=20.) # +ve value = reduction of load length + self.toolhead_unload_safety_margin = config.getfloat('toolhead_unload_safety_margin', 10., minval=0.) # Extra unload distance + self.toolhead_move_error_tolerance = config.getfloat('toolhead_move_error_tolerance', 60, minval=0, maxval=100) # Allowable delta movement % before error + self.toolhead_entry_tension_test = config.getint('toolhead_entry_tension_test', 1, minval=0, maxval=1) # Use filament compression to test for successful extruder entry (requires compression sensor) + self.toolhead_post_load_tighten = config.getint('toolhead_post_load_tighten', 60, minval=0, maxval=100) # Whether to apply filament tightening move after load (if not synced) + self.toolhead_post_load_tension_adjust = config.getint('toolhead_post_load_tension_adjust', 1, minval=0, maxval=1) # Whether to use sync-feedback sensor to adjust tension (synced) + + # Synchronous motor control + self.sync_to_extruder = config.getint('sync_to_extruder', 0, minval=0, maxval=1) + self.sync_form_tip = config.getint('sync_form_tip', 0, minval=0, maxval=1) + self.sync_purge = config.getint('sync_purge', 0, minval=0, maxval=1) + if self.mmu_machine.filament_always_gripped: + self.sync_to_extruder = self.sync_form_tip = self.sync_purge = 1 + + # TMC current control + self.extruder_collision_homing_current = config.getint('extruder_collision_homing_current', 50, minval=10, maxval=100) + self.extruder_form_tip_current = config.getint('extruder_form_tip_current', 100, minval=100, maxval=150) + self.extruder_purge_current = config.getint('extruder_purge_current', 100, minval=100, maxval=150) + self.sync_gear_current = config.getint('sync_gear_current', 50, minval=10, maxval=100) + + # Filament move speeds and accelaration + self.gear_from_buffer_speed = config.getfloat('gear_from_buffer_speed', 150., minval=10.) + self.gear_from_buffer_accel = config.getfloat('gear_from_buffer_accel', 400, minval=10.) + self.gear_from_spool_speed = config.getfloat('gear_from_spool_speed', 60, minval=10.) + self.gear_from_spool_accel = config.getfloat('gear_from_spool_accel', 100, minval=10.) + self.gear_unload_speed = config.getfloat('gear_unload_speed', self.gear_from_spool_speed, minval=10.) + self.gear_unload_accel = config.getfloat('gear_unload_accel', self.gear_from_spool_accel, minval=10.) + self.gear_short_move_speed = config.getfloat('gear_short_move_speed', 60., minval=1.) + self.gear_short_move_accel = config.getfloat('gear_short_move_accel', 400, minval=10.) + self.gear_short_move_threshold = config.getfloat('gear_short_move_threshold', self.gate_homing_max, minval=1.) + self.gear_homing_speed = config.getfloat('gear_homing_speed', 150, minval=1.) + + self.extruder_load_speed = config.getfloat('extruder_load_speed', 15, minval=1.) + self.extruder_unload_speed = config.getfloat('extruder_unload_speed', 15, minval=1.) + self.extruder_sync_load_speed = config.getfloat('extruder_sync_load_speed', 15., minval=1.) + self.extruder_sync_unload_speed = config.getfloat('extruder_sync_unload_speed', 15., minval=1.) + self.extruder_accel = config.getfloat('extruder_accel', 400, above=10.) + self.extruder_homing_speed = config.getfloat('extruder_homing_speed', 15, minval=1.) + + self.gear_buzz_accel = config.getfloat('gear_buzz_accel', 1000, minval=10.) # Not exposed + + self.macro_toolhead_max_accel = config.getfloat('macro_toolhead_max_accel', 0, minval=0) + self.macro_toolhead_min_cruise_ratio = config.getfloat('macro_toolhead_min_cruise_ratio', minval=0., below=1.) + if self.macro_toolhead_max_accel == 0: + self.macro_toolhead_max_accel = config.getsection('printer').getsection('toolhead').getint('max_accel', 5000) + + # eSpooler + self.espooler_min_distance = config.getfloat('espooler_min_distance', 50., above=0) + self.espooler_max_stepper_speed = config.getfloat('espooler_max_stepper_speed', 300., above=0) + self.espooler_min_stepper_speed = config.getfloat('espooler_min_stepper_speed', 0., minval=0., below=self.espooler_max_stepper_speed) + self.espooler_speed_exponent = config.getfloat('espooler_speed_exponent', 0.5, above=0) + self.espooler_assist_reduced_speed = config.getint('espooler_assist_reduced_speed', 50, minval=0, maxval=100) + self.espooler_printing_power = config.getint('espooler_printing_power', 0, minval=0, maxval=100) + self.espooler_assist_extruder_move_length = config.getfloat("espooler_assist_extruder_move_length", 100, above=10.) + self.espooler_assist_burst_power = config.getint("espooler_assist_burst_power", 50, minval=0, maxval=100) + self.espooler_assist_burst_duration = config.getfloat("espooler_assist_burst_duration", .4, above=0., maxval=10.) + self.espooler_assist_burst_trigger = config.getint("espooler_assist_burst_trigger", 0, minval=0, maxval=1) + self.espooler_assist_burst_trigger_max = config.getint("espooler_assist_burst_trigger_max", 3, minval=1) + self.espooler_rewind_burst_power = config.getint("espooler_rewind_burst_power", 50, minval=0, maxval=100) + self.espooler_rewind_burst_duration = config.getfloat("espooler_rewind_burst_duration", .4, above=0., maxval=10.) + self.espooler_operations = list(config.getlist('espooler_operations', self.ESPOOLER_OPERATIONS)) + + # Optional features + self.has_filament_buffer = bool(config.getint('has_filament_buffer', 1, minval=0, maxval=1)) + self.preload_attempts = config.getint('preload_attempts', 1, minval=1, maxval=20) # How many times to try to grab the filament + self.encoder_move_validation = config.getint('encoder_move_validation', 1, minval=0, maxval=1) # Use encoder to check load/unload movement + self.spoolman_support = config.getchoice('spoolman_support', {o: o for o in self.SPOOLMAN_OPTIONS}, self.SPOOLMAN_OFF) + self.t_macro_color = config.getchoice('t_macro_color', {o: o for o in self.T_MACRO_COLOR_OPTIONS}, self.T_MACRO_COLOR_SLICER) + self.default_endless_spool_enabled = config.getint('endless_spool_enabled', 0, minval=0, maxval=1) + self.endless_spool_on_load = config.getint('endless_spool_on_load', 0, minval=0, maxval=1) + self.endless_spool_eject_gate = config.getint('endless_spool_eject_gate', -1, minval=-1, maxval=self.num_gates - 1) + self.default_endless_spool_groups = list(config.getintlist('endless_spool_groups', [])) + self.tool_extrusion_multipliers = [] + self.tool_speed_multipliers = [] + self.select_tool_macro = config.get('select_tool_macro', default=None) + self.select_tool_num_switches = config.getint('select_tool_num_switches', default=0, minval=0) + + # Logging + self.log_level = config.getint('log_level', 1, minval=0, maxval=4) + self.log_file_level = config.getint('log_file_level', 2, minval=-1, maxval=4) + self.log_statistics = config.getint('log_statistics', 0, minval=0, maxval=1) + self.log_visual = config.getint('log_visual', 1, minval=0, maxval=1) + self.log_startup_status = config.getint('log_startup_status', 1, minval=0, maxval=2) + self.log_m117_messages = config.getint('log_m117_messages', 1, minval=0, maxval=1) + + # Cosmetic console stuff + self.console_stat_columns = list(config.getlist('console_stat_columns', ['unload', 'load', 'total'])) + self.console_stat_rows = list(config.getlist('console_stat_rows', ['total', 'job', 'job_average'])) + self.console_gate_stat = config.getchoice('console_gate_stat', {o: o for o in self.GATE_STATS_TYPES}, self.GATE_STATS_STRING) + self.console_always_output_full = config.getint('console_always_output_full', 1, minval=0, maxval=1) + + # Turn off splash bling for boring people + self.serious = config.getint('serious', 0, minval=0, maxval=1) + # Suppress the Kalico warning for dangerous people + self.suppress_kalico_warning = config.getint('suppress_kalico_warning', 0, minval=0, maxval=1) + + # Currently hidden and testing options + self.test_random_failures = config.getint('test_random_failures', 0, minval=0, maxval=1) + self.test_disable_encoder = config.getint('test_disable_encoder', 0, minval=0, maxval=1) + self.test_force_in_print = config.getint('test_force_in_print', 0, minval=0, maxval=1) + + # Klipper tuning (aka hacks) + # Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing + # operations especially so with CANbus connected mcus. Happy Hare using many homing moves for reliable extruder loading + # and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error. + self.update_trsync = config.getint('update_trsync', 0, minval=0, maxval=1) + + # Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms + # timeout will kill the print. Since it seems to occur only on homing moves perhaps because of too + # high a microstep setting or speed. They can be safely retried to workaround. + # This has been working well in practice. + self.canbus_comms_retries = config.getint('canbus_comms_retries', 3, minval=1, maxval=10) + + # Older neopixels have very finiky timing and can generate lots of "Unable to obtain 'neopixel_result' response" + # errors in klippy.log. This has been linked to subsequent Timer too close errors. An often cited workaround is + # to increase BIT_MAX_TIME in neopixel.py. This option does that automatically for you to save dirtying klipper. + self.update_bit_max_time = config.getint('update_bit_max_time', 0, minval=0, maxval=1) + + # This is required for the BTT AHT10 used on the ViViD MMU + self.update_aht10_commands = config.getint('update_aht10_commands', 0, minval=0, maxval=1) + + # Initialize manager helpers + # These encapsulate specific functionality to reduce the complexity of main class + self.sync_feedback_manager = MmuSyncFeedbackManager(self) + self.environment_manager = MmuEnvironmentManager(self) + + # Establish defaults for "reset" operation ---------------------------------------------------------- + # These lists are the defaults (used when reset) and will be overriden by values in mmu_vars.cfg... + + # Endless spool groups + self.endless_spool_enabled = self.default_endless_spool_enabled + if len(self.default_endless_spool_groups) > 0: + if self.endless_spool_enabled == 1 and len(self.default_endless_spool_groups) != self.num_gates: + raise self.config.error("endless_spool_groups has a different number of values than the number of gates") + else: + self.default_endless_spool_groups = list(range(self.num_gates)) + self.endless_spool_groups = list(self.default_endless_spool_groups) + + # Components of the gate map (status, material, color, spool_id, filament name, temperature, and speed override) + self.gate_map_vars = [ (self.VARS_MMU_GATE_STATUS, 'gate_status', self.GATE_UNKNOWN), + (self.VARS_MMU_GATE_FILAMENT_NAME, 'gate_filament_name', ""), + (self.VARS_MMU_GATE_MATERIAL, 'gate_material', ""), + (self.VARS_MMU_GATE_COLOR, 'gate_color', ""), + (self.VARS_MMU_GATE_TEMPERATURE, 'gate_temperature', int(self.default_extruder_temp)), + (self.VARS_MMU_GATE_SPOOL_ID, 'gate_spool_id', -1), + (self.VARS_MMU_GATE_SPEED_OVERRIDE, 'gate_speed_override', 100) ] + + for _, attr, default in self.gate_map_vars: + default_attr_name = "default_" + attr + default_attr = getattr(self, default_attr_name) + if len(default_attr) > 0: + if len(default_attr) != self.num_gates: + raise self.config.error("%s has different number of entries than the number of gates" % attr) + else: + default_attr.extend([default] * self.num_gates) + setattr(self, attr, list(default_attr)) + self._update_gate_color_rgb() + + # Tool to gate mapping + if len(self.default_ttg_map) > 0: + if not len(self.default_ttg_map) == self.num_gates: + raise self.config.error("tool_to_gate_map has different number of values than the number of gates") + else: + self.default_ttg_map = list(range(self.num_gates)) + self.ttg_map = list(self.default_ttg_map) + + # Tool speed and extrusion multipliers + self.tool_extrusion_multipliers.extend([1.] * self.num_gates) + self.tool_speed_multipliers.extend([1.] * self.num_gates) + + # Register GCODE commands --------------------------------------------------------------------------- + + # Logging and Stats + self.gcode.register_command('MMU_RESET', self.cmd_MMU_RESET, desc = self.cmd_MMU_RESET_help) + self.gcode.register_command('MMU_STATS', self.cmd_MMU_STATS, desc = self.cmd_MMU_STATS_help) + self.gcode.register_command('MMU_STATUS', self.cmd_MMU_STATUS, desc = self.cmd_MMU_STATUS_help) + self.gcode.register_command('MMU_SENSORS', self.cmd_MMU_SENSORS, desc = self.cmd_MMU_SENSORS_help) + + # Calibration + self.gcode.register_command('MMU_CALIBRATE_GEAR', self.cmd_MMU_CALIBRATE_GEAR, desc=self.cmd_MMU_CALIBRATE_GEAR_help) + self.gcode.register_command('MMU_CALIBRATE_ENCODER', self.cmd_MMU_CALIBRATE_ENCODER, desc=self.cmd_MMU_CALIBRATE_ENCODER_help) + self.gcode.register_command('MMU_CALIBRATE_BOWDEN', self.cmd_MMU_CALIBRATE_BOWDEN, desc = self.cmd_MMU_CALIBRATE_BOWDEN_help) + self.gcode.register_command('MMU_CALIBRATE_GATES', self.cmd_MMU_CALIBRATE_GATES, desc = self.cmd_MMU_CALIBRATE_GATES_help) + self.gcode.register_command('MMU_CALIBRATE_TOOLHEAD', self.cmd_MMU_CALIBRATE_TOOLHEAD, desc = self.cmd_MMU_CALIBRATE_TOOLHEAD_help) + self.gcode.register_command('MMU_CALIBRATE_PSENSOR', self.cmd_MMU_CALIBRATE_PSENSOR, desc = self.cmd_MMU_CALIBRATE_PSENSOR_help) + + # Motor control + self.gcode.register_command('MMU_MOTORS_OFF', self.cmd_MMU_MOTORS_OFF, desc = self.cmd_MMU_MOTORS_OFF_help) + self.gcode.register_command('MMU_MOTORS_ON', self.cmd_MMU_MOTORS_ON, desc = self.cmd_MMU_MOTORS_ON_help) + self.gcode.register_command('MMU_SYNC_GEAR_MOTOR', self.cmd_MMU_SYNC_GEAR_MOTOR, desc=self.cmd_MMU_SYNC_GEAR_MOTOR_help) + + # Core MMU functionality + self.gcode.register_command('MMU', self.cmd_MMU, desc = self.cmd_MMU_help) + self.gcode.register_command('MMU_LOG', self.cmd_MMU_LOG, desc = self.cmd_MMU_LOG_help) + self.gcode.register_command('MMU_HELP', self.cmd_MMU_HELP, desc = self.cmd_MMU_HELP_help) + self.gcode.register_command('MMU_ENCODER', self.cmd_MMU_ENCODER, desc = self.cmd_MMU_ENCODER_help) + self.gcode.register_command('MMU_ESPOOLER', self.cmd_MMU_ESPOOLER, desc = self.cmd_MMU_ESPOOLER_help) + self.gcode.register_command('MMU_HOME', self.cmd_MMU_HOME, desc = self.cmd_MMU_HOME_help) + self.gcode.register_command('MMU_SELECT', self.cmd_MMU_SELECT, desc = self.cmd_MMU_SELECT_help) + self.gcode.register_command('MMU_SELECT_BYPASS', self.cmd_MMU_SELECT_BYPASS, desc = self.cmd_MMU_SELECT_BYPASS_help) # Alias for MMU_SELECT BYPASS=1 + self.gcode.register_command('MMU_PRELOAD', self.cmd_MMU_PRELOAD, desc = self.cmd_MMU_PRELOAD_help) + self.gcode.register_command('MMU_CHANGE_TOOL', self.cmd_MMU_CHANGE_TOOL, desc = self.cmd_MMU_CHANGE_TOOL_help) + # TODO Currently cannot not registered directly as Tx commands because cannot attach color/spool_id required by Mailsail + #for tool in range(self.num_gates): + # self.gcode.register_command('T%d' % tool, self.cmd_MMU_CHANGE_TOOL, desc = "Change to tool T%d" % tool) + self.gcode.register_command('MMU_LOAD', self.cmd_MMU_LOAD, desc=self.cmd_MMU_LOAD_help) + self.gcode.register_command('MMU_EJECT', self.cmd_MMU_EJECT, desc = self.cmd_MMU_EJECT_help) + self.gcode.register_command('MMU_UNLOAD', self.cmd_MMU_UNLOAD, desc = self.cmd_MMU_UNLOAD_help) + self.gcode.register_command('MMU_PAUSE', self.cmd_MMU_PAUSE, desc = self.cmd_MMU_PAUSE_help) + self.gcode.register_command('MMU_UNLOCK', self.cmd_MMU_UNLOCK, desc = self.cmd_MMU_UNLOCK_help) + self.gcode.register_command('MMU_RECOVER', self.cmd_MMU_RECOVER, desc = self.cmd_MMU_RECOVER_help) + + # Endstops for print start / stop. Automatically called if printing from virtual SD-card + self.gcode.register_command('MMU_PRINT_START', self.cmd_MMU_PRINT_START, desc = self.cmd_MMU_PRINT_START_help) + self.gcode.register_command('MMU_PRINT_END', self.cmd_MMU_PRINT_END, desc = self.cmd_MMU_PRINT_END_help) + + # User Setup and Testing + self.gcode.register_command('MMU_TEST_BUZZ_MOTOR', self.cmd_MMU_TEST_BUZZ_MOTOR, desc=self.cmd_MMU_TEST_BUZZ_MOTOR_help) + self.gcode.register_command('MMU_TEST_GRIP', self.cmd_MMU_TEST_GRIP, desc = self.cmd_MMU_TEST_GRIP_help) + self.gcode.register_command('MMU_TEST_LOAD', self.cmd_MMU_TEST_LOAD, desc=self.cmd_MMU_TEST_LOAD_help) + self.gcode.register_command('MMU_TEST_MOVE', self.cmd_MMU_TEST_MOVE, desc = self.cmd_MMU_TEST_MOVE_help) + self.gcode.register_command('MMU_TEST_HOMING_MOVE', self.cmd_MMU_TEST_HOMING_MOVE, desc = self.cmd_MMU_TEST_HOMING_MOVE_help) + self.gcode.register_command('MMU_TEST_TRACKING', self.cmd_MMU_TEST_TRACKING, desc=self.cmd_MMU_TEST_TRACKING_help) + self.gcode.register_command('MMU_TEST_CONFIG', self.cmd_MMU_TEST_CONFIG, desc = self.cmd_MMU_TEST_CONFIG_help) + self.gcode.register_command('MMU_TEST_RUNOUT', self.cmd_MMU_TEST_RUNOUT, desc = self.cmd_MMU_TEST_RUNOUT_help) + self.gcode.register_command('MMU_TEST_FORM_TIP', self.cmd_MMU_TEST_FORM_TIP, desc = self.cmd_MMU_TEST_FORM_TIP_help) + self.gcode.register_command('MMU_TEST_PURGE', self.cmd_MMU_TEST_PURGE, desc = self.cmd_MMU_TEST_PURGE_help) + + # Soak Testing + self.gcode.register_command('MMU_SOAKTEST_LOAD_SEQUENCE', self.cmd_MMU_SOAKTEST_LOAD_SEQUENCE, desc = self.cmd_MMU_SOAKTEST_LOAD_SEQUENCE_help) + + # Mapping stuff (TTG, Gate map, Slicer toolmap, Endless spool, Spoolman) + self.gcode.register_command('MMU_TTG_MAP', self.cmd_MMU_TTG_MAP, desc = self.cmd_MMU_TTG_MAP_help) + self.gcode.register_command('MMU_GATE_MAP', self.cmd_MMU_GATE_MAP, desc = self.cmd_MMU_GATE_MAP_help) + self.gcode.register_command('MMU_ENDLESS_SPOOL', self.cmd_MMU_ENDLESS_SPOOL, desc = self.cmd_MMU_ENDLESS_SPOOL_help) + self.gcode.register_command('MMU_CHECK_GATE', self.cmd_MMU_CHECK_GATE, desc = self.cmd_MMU_CHECK_GATE_help) + self.gcode.register_command('MMU_TOOL_OVERRIDES', self.cmd_MMU_TOOL_OVERRIDES, desc = self.cmd_MMU_TOOL_OVERRIDES_help) + self.gcode.register_command('MMU_SLICER_TOOL_MAP', self.cmd_MMU_SLICER_TOOL_MAP, desc = self.cmd_MMU_SLICER_TOOL_MAP_help) + self.gcode.register_command('MMU_CALC_PURGE_VOLUMES', self.cmd_MMU_CALC_PURGE_VOLUMES, desc = self.cmd_MMU_CALC_PURGE_VOLUMES_help) + self.gcode.register_command('MMU_SPOOLMAN', self.cmd_MMU_SPOOLMAN, desc = self.cmd_MMU_SPOOLMAN_help) + + # For use in user controlled load and unload macros + self.gcode.register_command('_MMU_STEP_LOAD_GATE', self.cmd_MMU_STEP_LOAD_GATE, desc = self.cmd_MMU_STEP_LOAD_GATE_help) + self.gcode.register_command('_MMU_STEP_UNLOAD_GATE', self.cmd_MMU_STEP_UNLOAD_GATE, desc = self.cmd_MMU_STEP_UNLOAD_GATE_help) + self.gcode.register_command('_MMU_STEP_LOAD_BOWDEN', self.cmd_MMU_STEP_LOAD_BOWDEN, desc = self.cmd_MMU_STEP_LOAD_BOWDEN_help) + self.gcode.register_command('_MMU_STEP_UNLOAD_BOWDEN', self.cmd_MMU_STEP_UNLOAD_BOWDEN, desc = self.cmd_MMU_STEP_UNLOAD_BOWDEN_help) + self.gcode.register_command('_MMU_STEP_HOME_EXTRUDER', self.cmd_MMU_STEP_HOME_EXTRUDER, desc = self.cmd_MMU_STEP_HOME_EXTRUDER_help) + self.gcode.register_command('_MMU_STEP_LOAD_TOOLHEAD', self.cmd_MMU_STEP_LOAD_TOOLHEAD, desc = self.cmd_MMU_STEP_LOAD_TOOLHEAD_help) + self.gcode.register_command('_MMU_STEP_UNLOAD_TOOLHEAD', self.cmd_MMU_STEP_UNLOAD_TOOLHEAD, desc = self.cmd_MMU_STEP_UNLOAD_TOOLHEAD_help) + self.gcode.register_command('_MMU_STEP_HOMING_MOVE', self.cmd_MMU_STEP_HOMING_MOVE, desc = self.cmd_MMU_STEP_HOMING_MOVE_help) + self.gcode.register_command('_MMU_STEP_MOVE', self.cmd_MMU_STEP_MOVE, desc = self.cmd_MMU_STEP_MOVE_help) + self.gcode.register_command('_MMU_STEP_SET_FILAMENT', self.cmd_MMU_STEP_SET_FILAMENT, desc = self.cmd_MMU_STEP_SET_FILAMENT_help) + self.gcode.register_command('_MMU_STEP_SET_ACTION', self.cmd_MMU_STEP_SET_ACTION, desc = self.cmd_MMU_STEP_SET_ACTION_help) + self.gcode.register_command('_MMU_M400', self.cmd_MMU_M400, desc = self.cmd_MMU_M400_help) # Wait on both movequeues + + # Internal handlers for Runout & Insertion for all sensor options + self.gcode.register_command('__MMU_ENCODER_RUNOUT', self.cmd_MMU_ENCODER_RUNOUT, desc = self.cmd_MMU_ENCODER_RUNOUT_help) + self.gcode.register_command('__MMU_ENCODER_INSERT', self.cmd_MMU_ENCODER_INSERT, desc = self.cmd_MMU_ENCODER_INSERT_help) + self.gcode.register_command('__MMU_SENSOR_RUNOUT', self.cmd_MMU_SENSOR_RUNOUT, desc = self.cmd_MMU_SENSOR_RUNOUT_help) + self.gcode.register_command('__MMU_SENSOR_REMOVE', self.cmd_MMU_SENSOR_REMOVE, desc = self.cmd_MMU_SENSOR_REMOVE_help) + self.gcode.register_command('__MMU_SENSOR_INSERT', self.cmd_MMU_SENSOR_INSERT, desc = self.cmd_MMU_SENSOR_INSERT_help) + self.gcode.register_command('__MMU_SENSOR_CLOG', self.cmd_MMU_SENSOR_CLOG, desc = self.cmd_MMU_SENSOR_CLOG_help) + self.gcode.register_command('__MMU_SENSOR_TANGLE', self.cmd_MMU_SENSOR_TANGLE, desc = self.cmd_MMU_SENSOR_TANGLE_help) + + # Initializer tasks + self.gcode.register_command('__MMU_BOOTUP', self.cmd_MMU_BOOTUP, desc = self.cmd_MMU_BOOTUP_help) # Bootup tasks + + # Load development test commands + _ = MmuTest(self) + + # Apply Klipper hacks ------------------------------------------------------------------------------- + if self.update_trsync: # Timer too close mitigation + try: + import mcu + mcu.TRSYNC_TIMEOUT = max(mcu.TRSYNC_TIMEOUT, 0.05) + except Exception as e: + self.log_error("Unable to update TRSYNC_TIMEOUT: %s" % str(e)) + + if self.update_bit_max_time: # Neopixel update error mitigation + try: + from extras import neopixel + neopixel.BIT_MAX_TIME = max(neopixel.BIT_MAX_TIME, 0.000030) + except Exception as e: + self.log_error("Unable to update BIT_MAX_TIME: %s" % str(e)) + + if self.update_aht10_commands: # Command set of AHT10 (on ViViD) + try: + from extras import aht10 + aht10.AHT10_COMMANDS = { + 'INIT' :[0xBE, 0x08, 0x00], + 'MEASURE' :[0xAC, 0x33, 0x00], + 'RESET' :[0xBE, 0x08, 0x00] + } + except Exception as e: + self.log_error("Unable to update AHT10_COMMANDS: %s" % str(e)) + + # Initialize state and statistics variables + self.reinit() + self._reset_statistics() + self.counters = {} + + # Initialize MMU hardare. Note that logging not set up yet so use main klippy logger + def _setup_mmu_hardware(self, config): + logging.info("MMU: Hardware Initialization -------------------------------") + self.homing_extruder = self.mmu_machine.homing_extruder + + # Dynamically instantiate the selector class + self.selector = globals()[self.mmu_machine.selector_type](self) + if not isinstance(self.selector, BaseSelector): + raise self.config.error("Invalid Selector class for MMU") + + # Now we can instantiate the MMU toolhead + self.mmu_toolhead = MmuToolHead(config, self) + rails = self.mmu_toolhead.get_kinematics().rails + self.gear_rail = rails[1] + self.mmu_extruder_stepper = self.mmu_toolhead.mmu_extruder_stepper # Will be a MmuExtruderStepper if 'self.homing_extruder' is True + + # Setup filament sensors that are also used for homing (endstops). Must be done during initialization + self.sensor_manager = MmuSensorManager(self) + self.led_manager = MmuLedManager(self) + + # Get optional encoder setup. TODO Multi-encoder: rework to default name to None and then use lookup to determine if present + self.encoder_name = config.get('encoder_name', 'mmu_encoder') + self.encoder_sensor = self.printer.lookup_object('mmu_encoder %s' % self.encoder_name, None) + if not self.encoder_sensor: + logging.warning("MMU: No [mmu_encoder] definition found in mmu_hardware.cfg. Assuming encoder is not available") + + # Load espooler if it exists + self.espooler = self.printer.lookup_object('mmu_espooler mmu_espooler', None) + + def _setup_logging(self): + # Setup background file based logging before logging any messages + if self.mmu_logger is None and self.log_file_level >= 0: + logfile_path = self.printer.start_args['log_file'] + dirname = os.path.dirname(logfile_path) + if dirname is None: + mmu_log = '/tmp/mmu.log' + else: + mmu_log = dirname + '/mmu.log' + logging.info("MMU: Log: %s" % mmu_log) + self.mmu_logger = MmuLogger(mmu_log) + self.mmu_logger.log("\n\n\nMMU Startup -----------------------------------------------\n") + + def handle_connect(self): + self._setup_logging() + + self.toolhead = self.printer.lookup_object('toolhead') + self.sensor_manager.reset_active_unit(self.unit_selected) + + # Sanity check extruder name + extruder = self.printer.lookup_object(self.extruder_name, None) + if not extruder: + raise self.config.error("Extruder named '%s' not found on printer" % self.extruder_name) + + # See if we have a TMC controller capable of current control for filament collision detection and syncing + # on gear_stepper and tip forming on extruder + self.gear_tmc = self.extruder_tmc = None + for chip in mmu_machine.TMC_CHIPS: + if self.gear_tmc is None: + self.gear_tmc = self.printer.lookup_object('%s %s' % (chip, mmu_machine.GEAR_STEPPER_CONFIG), None) + if self.gear_tmc is not None: + self.log_debug("Found %s on gear_stepper. Current control enabled. Stallguard 'touch' homing possible." % chip) + if self.extruder_tmc is None: + self.extruder_tmc = self.printer.lookup_object("%s %s" % (chip, self.extruder_name), None) + if self.extruder_tmc is not None: + self.log_debug("Found %s on extruder. Current control enabled. %s" % (chip, "Stallguard 'touch' homing possible." if self.homing_extruder else "")) + if self.gear_tmc is None: + self.log_debug("TMC driver not found for gear_stepper, cannot use current reduction for collision detection or while synchronized printing") + if self.extruder_tmc is None: + self.log_debug("TMC driver not found for extruder, cannot use current increase for tip forming move") + + # Establish gear_stepper initial gear_stepper and extruder currents and current percentage + self.gear_default_run_current = self.gear_tmc.get_status(0)['run_current'] if self.gear_tmc else None + self.extruder_default_run_current = self.extruder_tmc.get_status(0)['run_current'] if self.extruder_tmc else None + self.gear_percentage_run_current = self.extruder_percentage_run_current = 100 # Current run percentages + self._gear_current_locked = False # True if gear current is currently locked by wrap_gear_current() + + # Sanity check that required klipper options are enabled + self.print_stats = self.printer.lookup_object("print_stats", None) + if self.print_stats is None: + self.log_debug("[virtual_sdcard] is not found in config, advanced state control is not possible") + self.pause_resume = self.printer.lookup_object('pause_resume', None) + if self.pause_resume is None: + raise self.config.error("MMU requires [pause_resume] to work, please add it to your config!") + + # Remember user setting of idle_timeout so it can be restored (if not overridden) + if self.default_idle_timeout < 0: + self.default_idle_timeout = self.printer.lookup_object("idle_timeout").idle_timeout + + # Sanity check to see that mmu_vars.cfg is included. This will verify path because default deliberately has 'mmu_revision' entry + self.save_variables = self.printer.lookup_object('save_variables', None) + if self.save_variables: + rd_var = self.save_variables.allVariables.get(self.VARS_MMU_GEAR_ROTATION_DISTANCE, None) + revision_var = self.save_variables.allVariables.get(self.VARS_MMU_REVISION, None) + if revision_var is None: + self.save_variables.allVariables[self.VARS_MMU_REVISION] = 0 + else: + rd_var = None + revision_var = None + if not self.save_variables or (rd_var is None and revision_var is None): + raise self.config.error("Calibration settings file (mmu_vars.cfg) not found. Check [save_variables] section in mmu_macro_vars.cfg\nAlso ensure you only have a single [save_variables] section defined in your printer config and it contains the line: mmu__revision = 0. If not, add this line and restart") + + # Create autotune manager to oversee calibration updates based on available telemetry + self.calibration_manager = MmuCalibrationManager(self) + + # Upgrade legacy or scalar variables to lists ------------------------------------------------------- + bowden_length = self.save_variables.allVariables.get(self.VARS_MMU_CALIB_BOWDEN_LENGTH, None) + if bowden_length: + self.log_debug("Upgrading %s variable" % (self.VARS_MMU_CALIB_BOWDEN_LENGTH)) + bowden_lengths = self._ensure_list_size([round(bowden_length, 1)], self.num_gates) + self.save_variables.allVariables.pop(self.VARS_MMU_CALIB_BOWDEN_LENGTH, None) + # Can't write file now so we let this occur naturally on next write + self.save_variables.allVariables[self.VARS_MMU_CALIB_BOWDEN_LENGTHS] = bowden_lengths + self.save_variables.allVariables[self.VARS_MMU_CALIB_BOWDEN_HOME] = self.gate_homing_endstop + + rotation_distance = self.save_variables.allVariables.get(self.VARS_MMU_GEAR_ROTATION_DISTANCE, None) + if rotation_distance: + self.log_debug("Upgrading %s and %s variables" % (self.VARS_MMU_GEAR_ROTATION_DISTANCE, self.VARS_MMU_CALIB_PREFIX)) + rotation_distances = [] + for i in range(self.num_gates): + ratio = self.save_variables.allVariables.get("%s%d" % (self.VARS_MMU_CALIB_PREFIX, i), 0) + rotation_distances.append(round(rotation_distance * ratio, 4)) + self.save_variables.allVariables.pop("%s%d" % (self.VARS_MMU_CALIB_PREFIX, i), None) + self.save_variables.allVariables.pop(self.VARS_MMU_GEAR_ROTATION_DISTANCE, None) + # Can't write file now so we let this occur naturally on next write + self.save_variables.allVariables[self.VARS_MMU_GEAR_ROTATION_DISTANCES] = rotation_distances + else: + self.save_variables.allVariables.pop("%s0" % self.VARS_MMU_CALIB_PREFIX, None) + + # Load bowden length configuration (calibration set with MMU_CALIBRATE_BOWDEN) ---------------------- + self.bowden_lengths = self.save_variables.allVariables.get(self.VARS_MMU_CALIB_BOWDEN_LENGTHS, None) + bowden_home = self.save_variables.allVariables.get(self.VARS_MMU_CALIB_BOWDEN_HOME, self.gate_homing_endstop) + if self.mmu_machine.require_bowden_move: + if self.bowden_lengths and bowden_home in self.GATE_ENDSTOPS: + self.bowden_lengths = [-1 if x < 0 else x for x in self.bowden_lengths] # Ensure -1 value for uncalibrated + # Ensure list size + if len(self.bowden_lengths) == self.num_gates: + self.log_debug("Loaded saved bowden lengths: %s" % self.bowden_lengths) + else: + self.log_error("Incorrect number of gates specified in %s. Adjusted length" % self.VARS_MMU_CALIB_BOWDEN_LENGTHS) + self.bowden_lengths = self._ensure_list_size(self.bowden_lengths, self.num_gates) + + # Ensure they are identical (just for optics) if variable_bowden_lengths is False + if not self.mmu_machine.variable_bowden_lengths: + self.bowden_lengths = [self.bowden_lengths[0]] * self.num_gates + + self.calibration_manager.adjust_bowden_lengths_on_homing_change() + if not any(x == -1 for x in self.bowden_lengths): + self.calibration_status |= self.CALIBRATED_BOWDENS + else: + self.log_warning("Warning: Bowden lengths not found in mmu_vars.cfg. Probably not calibrated yet") + self.bowden_lengths = [-1] * self.num_gates + else: + self.bowden_lengths = [0] * self.num_gates + self.calibration_status |= self.CALIBRATED_BOWDENS + self.save_variables.allVariables[self.VARS_MMU_CALIB_BOWDEN_LENGTHS] = self.bowden_lengths + self.save_variables.allVariables[self.VARS_MMU_CALIB_BOWDEN_HOME] = bowden_home + + # Load gear rotation distance configuration (calibration set with MMU_CALIBRATE_GEAR) --------------- + self.default_rotation_distance = self.gear_rail.steppers[0].get_rotation_distance()[0] # TODO Should probably be per gear in case they are disimilar? + self.rotation_distances = self.save_variables.allVariables.get(self.VARS_MMU_GEAR_ROTATION_DISTANCES, None) + if self.rotation_distances: + self.rotation_distances = [-1 if x == 0 else x for x in self.rotation_distances] # Ensure -1 value for uncalibrated + # Ensure list size + if len(self.rotation_distances) == self.num_gates: + self.log_debug("Loaded saved gear rotation distances: %s" % self.rotation_distances) + else: + self.log_error("Incorrect number of gates specified in %s. Adjusted length" % self.VARS_MMU_GEAR_ROTATION_DISTANCES) + self.rotation_distances = self._ensure_list_size(self.rotation_distances, self.num_gates) + + # Ensure they are identical (just for optics) if variable_rotation_distances is False + if not self.mmu_machine.variable_rotation_distances: + self.rotation_distances = [self.rotation_distances[0]] * self.num_gates + + if self.rotation_distances[0] != -1: + self.calibration_status |= self.CALIBRATED_GEAR_0 + if not any(x == -1 for x in self.rotation_distances): + self.calibration_status |= self.CALIBRATED_GEAR_RDS + else: + self.log_warning("Warning: Gear rotation distances not found in mmu_vars.cfg. Probably not calibrated yet") + self.rotation_distances = [-1] * self.num_gates + self.save_variables.allVariables[self.VARS_MMU_GEAR_ROTATION_DISTANCES] = self.rotation_distances + + # Load encoder configuration (calibration set with MMU_CALIBRATE_ENCODER) --------------------------- + self.encoder_resolution = 1.0 + if self.has_encoder(): + self.encoder_sensor.set_logger(self.log_debug) # Combine with MMU log + self.encoder_sensor.set_extruder(self.extruder_name) # Ensure it has extruder name + + # Setup FlowGuard mode and detection length + self.sync_feedback_manager.set_encoder_mode() + + # Setup resolution + self.encoder_resolution = self.encoder_sensor.get_resolution() # H/W config contains default + cal_res = self.save_variables.allVariables.get(self.VARS_MMU_ENCODER_RESOLUTION, None) + if cal_res: + self.encoder_resolution = cal_res + self.encoder_sensor.set_resolution(cal_res) + self.log_debug("Loaded saved encoder resolution: %.4f" % cal_res) + self.calibration_status |= self.CALIBRATED_ENCODER + else: + self.log_warning("Warning: Encoder resolution not found in mmu_vars.cfg. Probably not calibrated") + else: + self.calibration_status |= self.CALIBRATED_ENCODER # Pretend we are calibrated to avoid warnings + + # The threshold (mm) that determines real encoder movement (set to 1.5 pulses of encoder. i.e. to allow one rougue pulse) + self.encoder_min = 1.5 * self.encoder_resolution + + # Establish existence of Blobifier and filament cutter options + # TODO: A little bit hacky until a more universal approach is implemented + sequence_vars_macro = self.printer.lookup_object("gcode_macro _MMU_SEQUENCE_VARS", None) + if sequence_vars_macro: + self.has_blobifier = 'blob' in sequence_vars_macro.variables.get('user_post_load_extension', '').lower() # E.g. "BLOBIFIER" (old method of adding) + self.has_mmu_cutter = 'cut' in sequence_vars_macro.variables.get('user_post_unload_extension', '').lower() # E.g. "EREC_CUTTER_ACTION" + self.has_toolhead_cutter = 'cut' in self.form_tip_macro.lower() # E.g. "_MMU_CUT_TIP" + + # Sub components + for m in self.managers: + if hasattr(m, 'handle_connect'): + m.handle_connect() + + def _ensure_list_size(self, lst, size, default_value=-1): + lst = lst[:size] + lst.extend([default_value] * (size - len(lst))) + return lst + + def handle_disconnect(self): + self.log_debug('Klipper disconnected!') + + # Sub components + for m in self.managers: + if hasattr(m, 'handle_disconnect'): + m.handle_disconnect() + + def handle_ready(self): + self._can_write_variables = True + + # Pull retraction length from macro config + sequence_vars_macro = self.printer.lookup_object("gcode_macro _MMU_SEQUENCE_VARS", None) + if sequence_vars_macro: + park_toolchange = sequence_vars_macro.variables.get('park_toolchange',(0)) + self.toolchange_retract = park_toolchange[-1] + + # Reference correct extruder stepper which will definitely be available now + self.mmu_extruder_stepper = self.mmu_toolhead.mmu_extruder_stepper + if not self.homing_extruder: + self.log_debug("Warning: Using original klipper extruder stepper. Extruder homing not possible") + + # Restore state (only if fully calibrated) + self._load_persisted_state() + + # Setup events for managing internal print state machine + self.printer.register_event_handler("idle_timeout:printing", self._handle_idle_timeout_printing) + self.printer.register_event_handler("idle_timeout:ready", self._handle_idle_timeout_ready) + self.printer.register_event_handler("idle_timeout:idle", self._handle_idle_timeout_idle) + + self._setup_hotend_off_timer() + self._setup_pending_spool_id_timer() + self._clear_saved_toolhead_position() + + # This is a bit naughty to register commands here but I need to make sure we are the outermost wrapper + try: + prev_pause = self.gcode.register_command('PAUSE', None) + if prev_pause is not None: + self.gcode.register_command('__PAUSE', prev_pause) + self.gcode.register_command('PAUSE', self.cmd_PAUSE, desc = self.cmd_PAUSE_help) + else: + self.log_error('No existing PAUSE macro found!') + + prev_resume = self.gcode.register_command('RESUME', None) + if prev_resume is not None: + self.gcode.register_command('__RESUME', prev_resume) + self.gcode.register_command('RESUME', self.cmd_MMU_RESUME, desc = self.cmd_MMU_RESUME_help) + else: + self.log_error('No existing RESUME macro found!') + + prev_clear_pause = self.gcode.register_command('CLEAR_PAUSE', None) + if prev_clear_pause is not None: + self.gcode.register_command('__CLEAR_PAUSE', prev_clear_pause) + self.gcode.register_command('CLEAR_PAUSE', self.cmd_CLEAR_PAUSE, desc = self.cmd_CLEAR_PAUSE_help) + else: + self.log_error('No existing CLEAR_PAUSE macro found!') + + prev_cancel = self.gcode.register_command('CANCEL_PRINT', None) + if prev_cancel is not None: + self.gcode.register_command('__CANCEL_PRINT', prev_cancel) + self.gcode.register_command('CANCEL_PRINT', self.cmd_MMU_CANCEL_PRINT, desc = self.cmd_MMU_CANCEL_PRINT_help) + else: + self.log_error('No existing CANCEL_PRINT macro found!') + except Exception as e: + self.log_error('Error trying to wrap PAUSE/RESUME/CLEAR_PAUSE/CANCEL_PRINT macros: %s' % str(e)) + + # Sub components + for m in self.managers: + if hasattr(m, 'handle_ready'): + m.handle_ready() + + # Schedule bootup tasks to run after klipper and hopefully spoolman have settled + self._schedule_mmu_bootup_tasks(self.BOOT_DELAY) + + def reinit(self): + self.is_enabled = self.runout_enabled = True + self.runout_last_enable_time = self.reactor.monotonic() + self.is_handling_runout = self.calibrating = False + self.last_print_stats = self.paused_extruder_temp = self.reason_for_pause = None + self.tool_selected = self._next_tool = self.gate_selected = self.TOOL_GATE_UNKNOWN + self.unit_selected = 0 # Which MMU unit is active if more than one + self._last_toolchange = "Unknown" + self.active_filament = {} + self.filament_pos = self.FILAMENT_POS_UNKNOWN + self.filament_direction = self.DIRECTION_UNKNOWN + self.action = self.ACTION_IDLE + self._old_action = None + self._clear_saved_toolhead_position() + self._reset_job_statistics() + self.print_state = self.resume_to_state = "ready" + self.form_tip_vars = None # Current defaults of gcode variables for tip forming macro + self._clear_slicer_tool_map() + self.pending_spool_id = -1 # For automatic assignment of spool_id if set perhaps by rfid reader + self.saved_toolhead_max_accel = None + self.num_toolchanges = 0 + + # Sub components + for m in self.managers: + if hasattr(m, 'reinit'): + m.reinit() + + def _clear_slicer_tool_map(self): + skip = self.slicer_tool_map.get('skip_automap', False) if self.slicer_tool_map else False + self.slicer_tool_map = {'tools': {}, 'referenced_tools': [], 'initial_tool': None, 'purge_volumes': [], 'total_toolchanges': None} + self._restore_automap_option(skip) + self.slicer_color_rgb = [(0.,0.,0.)] * self.num_gates + self._update_t_macros() # Clear 'color' on Tx macros if displaying slicer colors + + def _restore_automap_option(self, skip=False): + self.slicer_tool_map['skip_automap'] = skip + + # Helper to infer type for setting gcode macro variables + def _fix_type(self, s): + try: + return float(s) + except ValueError: + try: + return int(s) + except ValueError: + return s + + # Helper to ensure int when strings may be passed from UI + def safe_int(self, i, default=0): + try: + return int(i) + except ValueError: + return default + + # Compare unicode strings with optional case insensitivity + def _compare_unicode(self, a, b, case_insensitive=True): + a = unicodedata.normalize('NFKC', a) + b = unicodedata.normalize('NFKC', b) + if case_insensitive: + a = a.lower() + b = b.lower() + return a == b + + # Format color string for display + def _format_color(self, color): + x = re.search(r"^([a-f\d]{6})(ff)?$", color, re.IGNORECASE) + if x is not None: + return '#' + x.group(1).upper() + + x = re.search(r"^([a-f\d]{6}([a-f\d]{2})?)$", color, re.IGNORECASE) + if x is not None: + return '#' + x.group().upper() + + return color + + # This retuns the hex color format without leading '#' E.g. ff00e080 + # Support alpha channel (Nice for Mainsail/Fluidd UI) + def _color_to_rgb_hex(self, color): + if color in self.w3c_colors: + color = self.w3c_colors.get(color) + elif color == '': + color = "000000" + rgb_hex = color.lstrip('#').lower() + return rgb_hex[0:8] + + # This retuns a convenient RGB fraction tuple for controlling LEDs E.g. (0.32, 0.56, 1.00) + # or integer version (82, 143, 255). Alpha channel is cut + def _color_to_rgb_tuple(self, color, fraction=True): + rgb_hex = self._color_to_rgb_hex(color)[:6] + length = len(rgb_hex) + if fraction: + if length % 3 == 0: + return tuple(round(float(int(rgb_hex[i:i + length // 3], 16)) / 255, 3) for i in range(0, length, length // 3)) + return (0.,0.,0.) + else: + if length % 3 == 0: + return tuple(int(rgb_hex[i:i+2], 16) for i in (0, 2, 4)) + return (0,0,0) + + # Helper to return validated color string or None if invalid + def _validate_color(self, color): + color = color.lower() + if color == "": + return "" + + # Try w3c named color + if color in self.w3c_colors: + return color + + # Try RGB color + color = color.lstrip('#').lower() + x = re.search(r"^([a-f\d]{6}([a-f\d]{2})?)$", color, re.IGNORECASE) + if x is not None and x.group() == color: + return color + + return None # Not valid + + # Helper for finding the closest color + # Example: + # color_list = ['123456', 'abcdef', '789abc', '4a7d9f', '010203'] + # _find_closest_color('4b7d8e', color_list) returns '4a7d9f' + def _find_closest_color(self, ref_color, color_list): + weighted_euclidean_distance = lambda color1, color2, weights=(0.3, 0.59, 0.11): ( + sum(weights[i] * (a - b) ** 2 for i, (a, b) in enumerate(zip(color1, color2))) + ) + ref_rgb = self._color_to_rgb_tuple(ref_color) + min_distance = float('inf') + closest_color = None + for color in color_list: + color_rgb = self._color_to_rgb_tuple(color) + distance = weighted_euclidean_distance(ref_rgb, color_rgb) + if distance < min_distance: + min_distance = distance + closest_color = color + return closest_color, min_distance + + # Helper to keep parallel RGB color map updated when color changes + def _update_gate_color_rgb(self): + # Recalculate RGB map for easy LED support + self.gate_color_rgb = [self._color_to_rgb_tuple(i) for i in self.gate_color] + + # Helper to keep parallel RGB color map updated when slicer color or TTG changes + # Will also update the t_macro colors + def _update_slicer_color_rgb(self): + self.slicer_color_rgb = [(0.,0.,0.)] * self.num_gates + for tool_key, tool_value in self.slicer_tool_map['tools'].items(): + tool = int(tool_key) + gate = self.ttg_map[tool] + self.slicer_color_rgb[gate] = self._color_to_rgb_tuple(tool_value['color']) + self._update_t_macros() + self.led_manager.gate_map_changed(None) # Force LED update + + # Helper to determine purge volume for toolchange + def _calc_purge_volume(self, from_tool, to_tool): + fil_diameter = 1.75 + volume = 0. + + if to_tool >= 0: + slicer_purge_volumes = self.slicer_tool_map['purge_volumes'] + if slicer_purge_volumes: + if from_tool >= 0: + volume = slicer_purge_volumes[from_tool][to_tool] + else: + # Assume worse case because we don't know from_tool + volume = max(row[to_tool] for row in slicer_purge_volumes) + + # Always add volume of residual filament (cut fragment and bit always left in the hotend) + volume += math.pi * ((fil_diameter / 2) ** 2) * (self.filament_remaining + self.toolhead_residual_filament) + return volume + + # Generate purge matrix based on filament colors + def _generate_purge_matrix(self, tool_colors, purge_min, purge_max, multiplier): + purge_vol_calc = PurgeVolCalculator(purge_min, purge_max, multiplier) + + # Build purge volume map (x=to_tool, y=from_tool) + should_calc = lambda x,y: x < len(tool_colors) and y < len(tool_colors) and x != y + purge_volumes = [ + [ + purge_vol_calc.calc_purge_vol_by_hex(tool_colors[y], tool_colors[x]) if should_calc(x,y) else 0 + for x in range(self.num_gates) + ] + for y in range(self.num_gates) + ] + return purge_volumes + + def _load_persisted_state(self): + self.log_debug("Loading persisted MMU state") + errors = [] + + # Always load length of filament remaining in extruder (after cut) and last tool loaded + self.filament_remaining = self.save_variables.allVariables.get(self.VARS_MMU_FILAMENT_REMAINING, self.filament_remaining) + self._last_tool = self.save_variables.allVariables.get(self.VARS_MMU_LAST_TOOL, self._last_tool) + + # Load EndlessSpool config + self.endless_spool_enabled = self.save_variables.allVariables.get(self.VARS_MMU_ENABLE_ENDLESS_SPOOL, self.endless_spool_enabled) + endless_spool_groups = self.save_variables.allVariables.get(self.VARS_MMU_ENDLESS_SPOOL_GROUPS, self.endless_spool_groups) + if len(endless_spool_groups) == self.num_gates: + self.endless_spool_groups = endless_spool_groups + else: + errors.append("Incorrect number of gates specified in %s" % self.VARS_MMU_ENDLESS_SPOOL_GROUPS) + + # Load TTG map + tool_to_gate_map = self.save_variables.allVariables.get(self.VARS_MMU_TOOL_TO_GATE_MAP, self.ttg_map) + if len(tool_to_gate_map) == self.num_gates: + self.ttg_map = tool_to_gate_map + else: + errors.append("Incorrect number of gates specified in %s" % self.VARS_MMU_TOOL_TO_GATE_MAP) + + # Load gate map + for var, attr, _ in self.gate_map_vars: + value = self.save_variables.allVariables.get(var, getattr(self, attr)) + if len(value) == self.num_gates: + setattr(self, attr, value) + else: + errors.append("Incorrect number of gates specified with %s" % var) + self._update_gate_color_rgb() + + # Load selected tool and gate + tool_selected = self.save_variables.allVariables.get(self.VARS_MMU_TOOL_SELECTED, self.tool_selected) + gate_selected = self.save_variables.allVariables.get(self.VARS_MMU_GATE_SELECTED, self.gate_selected) + if ( + not (self.TOOL_GATE_BYPASS <= gate_selected <= self.num_gates) or + gate_selected == self.TOOL_GATE_UNKNOWN + ): + errors.append("Invalid gate specified with %s or %s" % (self.VARS_MMU_TOOL_SELECTED, self.VARS_MMU_GATE_SELECTED)) + tool_selected = gate_selected = self.TOOL_GATE_UNKNOWN + + # Don't allow unknown gate on type-B MMU's (could also be first time bootup) + if self.mmu_machine.multigear and gate_selected == self.TOOL_GATE_UNKNOWN: + gate_selected = 0 + + self.selector.restore_gate(gate_selected) + self._set_gate_selected(gate_selected) + self._set_tool_selected(tool_selected) + self._ensure_ttg_match() # Ensure tool/gate consistency + + # Previous filament position + self.filament_pos = self.save_variables.allVariables.get(self.VARS_MMU_FILAMENT_POS, self.filament_pos) + + if len(errors) > 0: + self.log_warning("Warning: Some persisted state was ignored because it contained errors:\n%s" % '\n'.join(errors)) + + swap_stats = self.save_variables.allVariables.get(self.VARS_MMU_SWAP_STATISTICS, {}) + counters = self.save_variables.allVariables.get(self.VARS_MMU_COUNTERS, {}) + self.counters.update(counters) + + # Auto upgrade old names + key_map = {"time_spent_loading": "load", "time_spent_unloading": "unload", "time_spent_paused": "pause"} + swap_stats = {key_map.get(key, key): swap_stats[key] for key in swap_stats} + swap_stats.pop('servo_retries', None) # DEPRECATED + + self.statistics.update(swap_stats) + for gate in range(self.num_gates): + self.gate_statistics[gate] = dict(self.EMPTY_GATE_STATS_ENTRY) + gstats = self.save_variables.allVariables.get("%s%d" % (self.VARS_MMU_GATE_STATISTICS_PREFIX, gate), None) + if gstats: + self.gate_statistics[gate].update(gstats) + + def _schedule_mmu_bootup_tasks(self, delay=0.): + waketime = self.reactor.monotonic() + delay + self.reactor.register_callback(lambda pt: self._print_event("__MMU_BOOTUP"), waketime) + + def _fversion(self, v): + return "v{major}.{minor}.{patch}".format( + major=int(v), + minor=str(v).split('.')[1][0] if '.' in str(v) and len(str(v).split('.')[1]) > 0 else '0', + patch=str(v).split('.')[1][1:] if '.' in str(v) and len(str(v).split('.')[1]) > 1 else '0' + ) + + cmd_MMU_BOOTUP_help = "Internal commands to complete bootup of MMU" + def cmd_MMU_BOOTUP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + self.selector.bootup() + + try: + # Splash... + msg = '{1}(\_/){0}\n{1}( {0}*,*{1}){0}\n{1}(")_("){0} {5}{2}H{0}{3}a{0}{4}p{0}{2}p{0}{3}y{0} {4}H{0}{2}a{0}{3}r{0}{4}e{0} {1}%s{0} {2}R{0}{3}e{0}{4}a{0}{2}d{0}{3}y{0}{1}...{0}{6}' % self._fversion(self.config_version) + self.log_always(msg, color=True) + if self.kalico: + msg = "Warning: You are running on Kalico (Danger-Klipper). Support is not guaranteed!" + if self.suppress_kalico_warning: + self.log_trace(msg + " Message was suppressed.") + else: + self.log_warning(msg) + + # Look for filament_switch_sensors already configured to warn for possible conflicts + for section in self.config.get_prefix_sections('filament_switch_sensor'): + # Determine if this is created by HH or user + fsensor = self.printer.lookup_object(section.get_name()) + if not isinstance(fsensor.runout_helper, MmuRunoutHelper): + fsensor_name = section.get_name().split()[1] + pause_on_runout = section.getboolean('pause_on_runout', False) + pause_on_runout_msg = " and/or pause during prints unintentionally" if pause_on_runout else "" + self.log_warning("Warning: filament_switch_sensor '%s' found in printer configuration. This may interfere with MMU functionality%s." % (fsensor_name, pause_on_runout_msg)) + + self._set_print_state("initialized") + + # Use per gate sensors to adjust gate map + self.gate_status = self._validate_gate_status(self.gate_status) + + # Can we verify gate selected? If so fix now + gate_selected = self._validate_gate_selected() + if gate_selected is not None and gate_selected != self.gate_selected: + self.selector.restore_gate(gate_selected) + self._set_gate_selected(gate_selected) + self._ensure_ttg_match() # Ensure tool/gate consistency + + # Sanity check filament pos based only on non-intrusive tests and recover if necessary + if self.sensor_manager.check_all_sensors_after( + self.FILAMENT_POS_END_BOWDEN, self.gate_selected + ): + self._set_filament_pos_state(self.FILAMENT_POS_LOADED, silent=True) + + elif ( + (self.filament_pos == self.FILAMENT_POS_LOADED and + not self.sensor_manager.check_any_sensors_after(self.FILAMENT_POS_END_BOWDEN, self.gate_selected)) or + + (self.filament_pos == self.FILAMENT_POS_UNLOADED and + self.sensor_manager.check_any_sensors_in_path()) or + + self.filament_pos not in [self.FILAMENT_POS_LOADED, self.FILAMENT_POS_UNLOADED] + ): + self.recover_filament_pos(can_heat=False, message=True, silent=True) + + # Apply startup options + if self.startup_reset_ttg_map: + self._reset_ttg_map() + + if self.startup_home_if_unloaded and not self.check_if_not_calibrated(self.CALIBRATED_SELECTOR) and self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.home(0) + + if self.log_startup_status: + self.log_always(self._mmu_visual_to_string()) + self._display_visual_state() + self.report_necessary_recovery() + + # Ensure espooler print assist is correct + self._adjust_espooler_assist() + + # Initially disable clog/runout detection + self._disable_filament_monitoring() + + self.reset_sync_gear_to_extruder(False) # Intention is not to sync unless we have to + self.mmu_toolhead.quiesce() + + # Sync with spoolman. Delay as long as possible to maximize the chance it is contactable after startup/reboot + self._spoolman_sync() + + # Sync lane data to Moonraker for slicer integration and cleanup old lanes + self._moonraker_sync_lane_data() + + except Exception as e: + logging.error(traceback.format_exc()) + self.log_error('Error booting up MMU: %s' % str(e)) + + self.mmu_macro_event(self.MACRO_EVENT_RESTART) + + # Wrap execution of gcode command to allow for control over: + # - error handling + # - passing of additional variables + # - waiting on completion + def wrap_gcode_command(self, command, exception=False, variables=None, wait=False): + try: + command = command.replace("''", "") + macro = command.split()[0] + if not macro: return + + if variables: + gcode_macro = self.printer.lookup_object("gcode_macro %s" % macro, None) + if gcode_macro: + gcode_macro.variables.update(variables) + + self.log_trace("Running macro: %s%s" % (command, " (with override variables)" if variables is not None else "")) + + self.gcode.run_script_from_command(command) + if wait: + self.mmu_toolhead.quiesce() + + except Exception as e: + if exception is not None: + if exception: + raise MmuError("Error running %s: %s" % (macro, str(e))) + else: + self.log_error("Error running %s: %s" % (macro, str(e))) + else: + raise + + def mmu_macro_event(self, event_name, params=""): + if self.printer.lookup_object("gcode_macro %s" % self.mmu_event_macro, None) is not None: + self.wrap_gcode_command("%s EVENT=%s %s" % (self.mmu_event_macro, event_name, params)) + + # Wait on desired move queues + # TODO: perhaps better now to remove this method and + # TODO: always just call self.mmu_toolhead.quiesce() + def movequeues_wait(self, toolhead=True, mmu_toolhead=True): + #self.log_trace("movequeues_wait(toolhead=%s, mmu_toolhead=%s)" % (toolhead, mmu_toolhead)) + if toolhead: + self.toolhead.wait_moves() + if mmu_toolhead: + self.mmu_toolhead.wait_moves() + + # Dwell on desired move queues + def movequeues_dwell(self, dwell, toolhead=True, mmu_toolhead=True): + if dwell > 0.: + if toolhead: + self.toolhead.dwell(dwell) + if mmu_toolhead: + self.mmu_toolhead.dwell(dwell) + + +#################################### +# LOGGING AND STATISTICS FUNCTIONS # +#################################### + + def _get_action_string(self, action=None): + if action is None: + action = self.action + + return ("Idle" if action == self.ACTION_IDLE else + "Loading" if action == self.ACTION_LOADING else + "Unloading" if action == self.ACTION_UNLOADING else + "Loading Ext" if action == self.ACTION_LOADING_EXTRUDER else + "Exiting Ext" if action == self.ACTION_UNLOADING_EXTRUDER else + "Forming Tip" if action == self.ACTION_FORMING_TIP else + "Cutting Tip" if action == self.ACTION_CUTTING_TIP else + "Heating" if action == self.ACTION_HEATING else + "Checking" if action == self.ACTION_CHECKING else + "Homing" if action == self.ACTION_HOMING else + "Selecting" if action == self.ACTION_SELECTING else + "Cutting Filament" if action == self.ACTION_CUTTING_FILAMENT else + "Purging" if action == self.ACTION_PURGING else + "Unknown") # Error case - should not happen + + def _get_bowden_progress(self): + if self.bowden_start_pos is not None: + bowden_length = self.calibration_manager.get_bowden_length() + if bowden_length > 0: + current = self.get_encoder_distance(dwell=None) if self.has_encoder() else self._get_live_filament_position() + progress = abs(current - self.bowden_start_pos) / bowden_length + if self.filament_direction == self.DIRECTION_UNLOAD: + progress = 1 - progress + return round(max(0, min(100, progress * 100))) + return -1 + + # Returning new list() is so that clients like KlipperScreen sees the change + def get_status(self, eventtime): + status = { + 'enabled': self.is_enabled, + 'num_gates': self.num_gates, + 'is_homed': self.selector.is_homed, + 'is_locked': self.is_mmu_paused(), # DEPRECATED (alias for is_paused) + 'is_paused': self.is_mmu_paused(), # DEPRECATED (use print_state) + 'is_in_print': self.is_in_print(), # DEPRECATED (use print_state) + 'print_state': self.print_state, + 'unit': self.unit_selected, + 'tool': self.tool_selected, + 'gate': self._next_gate if self._next_gate is not None else self.gate_selected, + 'active_filament': self.active_filament, + 'num_toolchanges': self.num_toolchanges, + 'last_tool': self._last_tool, + 'next_tool': self._next_tool, + 'toolchange_purge_volume': self.toolchange_purge_volume, + 'last_toolchange': self._last_toolchange, + 'runout': self.is_handling_runout, # DEPRECATED (use operation) + 'operation': self.saved_toolhead_operation, + 'filament': "Loaded" if self.filament_pos == self.FILAMENT_POS_LOADED else + "Unloaded" if self.filament_pos == self.FILAMENT_POS_UNLOADED else + "Unknown", + 'filament_position': self.mmu_toolhead.get_position()[1], + 'filament_pos': self.filament_pos, # State machine position + 'filament_direction': self.filament_direction, + 'pending_spool_id': self.pending_spool_id, + 'ttg_map': self.ttg_map, + 'endless_spool_groups': self.endless_spool_groups, + 'gate_status': self.gate_status, + 'gate_filament_name': self.gate_filament_name, + 'gate_material': self.gate_material, + 'gate_color': self.gate_color, + 'gate_temperature': self.gate_temperature, + 'gate_spool_id': self.gate_spool_id, + 'gate_speed_override': self.gate_speed_override, + 'gate_color_rgb': self.gate_color_rgb, + 'slicer_color_rgb': self.slicer_color_rgb, + 'tool_extrusion_multipliers': self.tool_extrusion_multipliers, + 'tool_speed_multipliers': self.tool_speed_multipliers, + 'slicer_tool_map': self.slicer_tool_map, + 'action': self._get_action_string(), + 'has_bypass': self.selector.has_bypass(), # TODO deprecate because this is a per unit selector bypass + 'sync_drive': self.mmu_toolhead.is_synced(), + 'print_start_detection': self.print_start_detection, # For Klippain. Not really sure it is necessary + 'reason_for_pause': self.reason_for_pause if self.is_mmu_paused() else "", + 'extruder_filament_remaining': self.filament_remaining + self.toolhead_residual_filament, + 'spoolman_support': self.spoolman_support, + 'bowden_progress': self._get_bowden_progress(), # Simple 0-100%. -1 if not performing bowden move + 'espooler_active': self.espooler.get_operation(self.gate_selected)[0] if self.has_espooler() else '', + 'clog_detection': self.sync_feedback_manager.flowguard_encoder_mode, # DEPRECATED + 'clog_detection_enabled': self.sync_feedback_manager.flowguard_encoder_mode, # DEPRECATED + 'endless_spool': self.endless_spool_enabled, # DEPRECATED + 'endless_spool_enabled': self.endless_spool_enabled, # DEPRECATED + } + + # Sub components + for m in self.managers: + if hasattr(m, 'get_status'): + status.update(m.get_status(eventtime)) + + # Not yet refactored as manager class + if self.has_espooler(): + status.update(self.espooler.get_status(eventtime)) + + status['sensors'] = self.sensor_manager.get_status(eventtime) + if self.has_encoder(): + status['encoder'] = self.encoder_sensor.get_status(eventtime) + return status + + def _reset_statistics(self): + self.statistics = {} + self.last_statistics = {} + self.track = {} + self.gate_statistics = [] + for _ in range(self.num_gates): + self.gate_statistics.append(dict(self.EMPTY_GATE_STATS_ENTRY)) + self._reset_job_statistics() + + def _reset_job_statistics(self): + self.job_statistics = {} + + def _track_time_start(self, name): + self.track[name] = self.toolhead.get_last_move_time() + + def _track_time_end(self, name): + if name not in self.track: + return # Timer not initialized + self.statistics.setdefault(name, 0) + self.job_statistics.setdefault(name, 0) + elapsed = self.toolhead.get_last_move_time() - self.track[name] + self.statistics[name] += elapsed + self.job_statistics[name] += elapsed + self.last_statistics[name] = elapsed + + @contextlib.contextmanager + def _wrap_track_time(self, name): + self._track_time_start(name) + try: + yield self + finally: + self._track_time_end(name) + + def _track_swap_completed(self): + self.statistics.setdefault('total_swaps', 0) + self.job_statistics.setdefault('total_swaps', 0) + self.statistics.setdefault('swaps_since_pause', 0) + self.statistics.setdefault('swaps_since_pause_record', 0) + + self.statistics['swaps_since_pause'] += 1 + self.statistics['swaps_since_pause_record'] = max(self.statistics['swaps_since_pause_record'], self.statistics['swaps_since_pause']) + self.statistics['total_swaps'] += 1 + self.job_statistics['total_swaps'] += 1 + + def _track_pause_start(self): + self.statistics.setdefault('total_pauses', 0) + self.job_statistics.setdefault('total_pauses', 0) + + self.statistics['total_pauses'] += 1 + self.job_statistics['total_pauses'] += 1 + self.statistics['swaps_since_pause'] = 0 + + self._track_time_start('pause') + self._track_gate_statistics('pauses', self.gate_selected) + + def _track_pause_end(self): + self._track_time_end('pause') + + # Per gate tracking + def _track_gate_statistics(self, key, gate, count=1): + try: + if gate >= 0: + if isinstance(count, float): + self.gate_statistics[gate][key] = round(self.gate_statistics[gate][key] + count, 3) + else: + self.gate_statistics[gate][key] += count + except Exception as e: + self.log_debug("Exception whilst tracking gate stats: %s" % str(e)) + + def _seconds_to_short_string(self, seconds): + if isinstance(seconds, (float, int)) or seconds.isnumeric(): + s = int(seconds) + h = s // 3600 + m = (s // 60) % 60 + ms = int(round((seconds * 1000) % 1000, 0)) + s = s % 60 + + if h > 0: + return "{hour}:{min:0>2}:{sec:0>2}".format(hour=h, min=m, sec=s) + if m > 0: + return "{min}:{sec:0>2}".format(min=m, sec=s) + if s >= 10: + return "{sec}.{tenths}".format(sec=s, tenths=int(round(ms / 100, 0))) + return "{sec}.{hundreds:0>2}".format(sec=s, hundreds=int(round(ms / 10, 0))) + return seconds + + def _seconds_to_string(self, seconds): + result = "" + hours = int(math.floor(seconds / 3600.)) + if hours >= 1: + result += "%d hours " % hours + minutes = int(math.floor(seconds / 60.) % 60) + if hours >= 1 or minutes >= 1: + result += "%d minutes " % minutes + result += "%d seconds" % int((math.floor(seconds) % 60)) + return result + + def _swap_statistics_to_string(self, total=True, detail=False): + # + # +-----------+---------------------+----------------------+----------+ + # | 114(46) | unloading | loading | complete | + # | swaps | pre | - | post | pre | - | post | swap | + # +-----------+------+-------+------+------+-------+-------+----------+ + # | total | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | + # | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | + # | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | + # | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | + # | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | + # +-----------+------+-------+------+------+-------+-------+----------+ + # Time spent paused: ... + # + msg = "MMU Statistics:\n" + lifetime = self.statistics + job = self.job_statistics + last = self.last_statistics + total = self.console_always_output_full or total or not self.is_in_print() + + table_column_order = ['pre_unload', 'form_tip', 'unload', 'post_unload', 'pre_load', 'load', 'purge', 'post_load', 'total'] + table_include_columns = self._list_intersection(table_column_order, self.console_stat_columns if not detail else table_column_order) # To maintain the correct order and filter incorrect ones + + table_row_options = ['total', 'total_average', 'job', 'job_average', 'last'] + table_include_rows = self._list_intersection(self.console_stat_rows, table_row_options) # Keep the user provided order + + # Remove totals from table if not in print and not forcing total + if not self.console_always_output_full and not total: + if 'total' in table_include_rows: table_include_rows.remove('total') + if 'total_average' in table_include_rows: table_include_rows.remove('total_average') + if not self.is_in_print(): + if 'job' in table_include_rows: table_include_rows.remove('job') + if 'job_average' in table_include_rows: table_include_rows.remove('job_average') + + if len(table_include_rows) > 0: + # Map the row names (as described in macro_vars) to the proper values. stats is mandatory + table_rows_map = { + 'total': {'stats': lifetime, 'name': 'total '}, + 'total_average': {'stats': lifetime, 'name': UI_CASCADE + ' avg', 'devide': lifetime.get('total_swaps', 1)}, + 'job': {'stats': job, 'name': 'this job '}, + 'job_average': {'stats': job, 'name': UI_CASCADE + ' avg', 'devide': job.get('total_swaps', 1)}, + 'last': {'stats': last, 'name': 'last'} + } + # Map the saved timing values to proper column titles + table_headers_map = { + 'pre_unload': 'pre', + 'form_tip': 'tip', + 'unload': '-', + 'post_unload': 'post', + 'pre_load': 'pre', + 'load': '-', + 'purge': 'purge', + 'post_load': 'post', + 'total': 'swap' + } + # Group the top headers map. Omit the first column, because that'll be filled with the nr. of swaps + table_extra_headers_map = { + 'unloading': ['pre_unload', 'form_tip', 'unload', 'post_unload'], + 'loading': ['pre_load', 'load', 'purge', 'post_load'], + 'complete': ['total'] + } + # Extract the table headers that will be used + table_headers = [table_headers_map[key] for key in table_include_columns] + # Insert the first column. This is normally empty but will sit below the number of swaps + table_headers.insert(0, 'swaps') + + # Filter out the top (group) headers ( If none of the unload columns are present, unloading can be removed) + table_extra_headers = [key for key, values in table_extra_headers_map.items() if self._list_intersection(values, table_include_columns)] + + # Dictionary keys have no predefined order, so re-order them (Lucky the columns are alphabetical) + table_extra_headers.sort(reverse=True) + # Include the number of swaps in the top-left corner of the table + if self.is_in_print(): + if total: + table_extra_headers.insert(0, '%d(%d)' % (lifetime.get('total_swaps', 0), job.get('total_swaps', 0))) + else: + table_extra_headers.insert(0, '%d' % (job.get('total_swaps', 0))) + else: + table_extra_headers.insert(0, '%d' % (lifetime.get('total_swaps', 0))) + + # Build the table and populate with times + table = [] + for row in table_include_rows: + name = table_rows_map[row].get('name', row) + stats = table_rows_map[row]['stats'] + devide = max(1, table_rows_map[row].get('devide', 1)) + table.append([name]) + table[-1].extend(["-" if key not in stats else self._seconds_to_short_string(stats.get(key, 0) / devide) for key in table_include_columns]) + + # Calculate the needed column widths (The +2 is for a margin on both ends) + column_extra_header_widths = [len(table_extra_header) + 2 for table_extra_header in table_extra_headers] + column_widths = [max(len(table_headers[c]), max(len(row[c]) for row in table)) + 2 for c in range(len(table_include_columns) + 1) ] + + # If an 'extra_header' is wider then the sum of the columns beneath it, widen up those columns + for i, w in enumerate(column_extra_header_widths): + start = sum(max(1, len(self._list_intersection(table_extra_headers_map.get(table_extra_header, ['']), table_include_columns))) + for table_extra_header in table_extra_headers[0:i]) + end = start + max(1, len(self._list_intersection(table_extra_headers_map.get(table_extra_headers[i], ['']), table_include_columns))) + while (sum(column_widths[start:end]) + (end - start - 1)) < w: + for c in range(start, end): + column_widths[c] += 1 + column_extra_header_widths[i] = sum(column_widths[start:end]) + (end - start - 1) + + # Build the table header + msg += UI_BOX_TL + UI_BOX_T.join([UI_BOX_H * width for width in column_extra_header_widths]) + UI_BOX_TR + "\n" + msg += UI_BOX_V + UI_BOX_V.join([table_extra_headers[i].center(column_extra_header_widths[i], UI_SEPARATOR) + for i in range(len(column_extra_header_widths))]) + UI_BOX_V + "\n" + msg += UI_BOX_V + UI_BOX_V.join([table_headers[i].center(column_widths[i], UI_SEPARATOR) + for i in range(len(column_widths))]) + UI_BOX_V + "\n" + msg += UI_BOX_L + UI_BOX_M.join([UI_BOX_H * (width) for width in column_widths]) + UI_BOX_R + "\n" + + # Build the table body + for row in table: + msg += UI_BOX_V + UI_BOX_V.join([row[i].rjust(column_widths[i] - 1, UI_SEPARATOR) + UI_SEPARATOR + for i in range(len(column_widths))]) + UI_BOX_V + "\n" + + # Table footer + msg += UI_BOX_BL + UI_BOX_B.join([UI_BOX_H * width for width in column_widths]) + UI_BOX_BR + "\n" + + # Pause data + if total: + msg += "\n%s spent paused over %d pauses (All time)" % (self._seconds_to_short_string(lifetime.get('pause', 0)), lifetime.get('total_pauses', 0)) + if self.is_in_print(): + msg += "\n%s spent paused over %d pauses (This job)" % (self._seconds_to_short_string(job.get('pause', 0)), job.get('total_pauses', 0)) + if self.slicer_tool_map['total_toolchanges'] is not None: + msg += "\n%d / %d toolchanges" % (self.num_toolchanges, self.slicer_tool_map['total_toolchanges']) + else: + msg += "\n%d toolchanges" % self.num_toolchanges + msg += "\nNumber of swaps since last incident: %d (Record: %d)" % (lifetime.get('swaps_since_pause', 0), lifetime.get('swaps_since_pause_record', 0)) + + return msg + + def _list_intersection(self, list1, list2): + result = [] + for item in list1: + if item in list2: + result.append(item) + return result + + def _dump_statistics(self, force_log=False, total=False, job=False, gate=False, detail=False, showcounts=False): + msg = "" + if self.log_statistics or force_log: + if job or total: + msg += self._swap_statistics_to_string(total=total, detail=detail) + if self._can_use_encoder() and gate: + m,d = self._gate_statistics_to_string() + msg += "\n\n" if msg != "" else "" + msg += m + if detail: + msg += "\n" if msg != "" else "" + msg += d + + if showcounts and self.counters: + if msg: + msg += "\n\n" + msg += "Consumption counters:\n" + for counter, metric in self.counters.items(): + if metric['limit'] >= 0 and metric['count'] > metric['limit']: + msg += "Count %s: %d (above limit %d), Warning: %s" % (counter, metric['count'], metric['limit'], metric.get('warning', "")) + elif metric['limit'] >= 0: + msg += "Count %s: %d (limit %d%s)\n" % (counter, metric['count'], metric['limit'], ", will pause" if metric.get('pause', False) else "") + else: + msg += "Count %s: %d\n" % (counter, metric['count']) + + if msg: + self.log_always(msg) + + def _gate_statistics_to_string(self): + msg = "Gate Statistics:\n" + dbg = "" + t = self.console_gate_stat + for gate in range(self.num_gates): + #rounded = {k:round(v,1) if isinstance(v,float) else v for k,v in self.gate_statistics[gate].items()} + rounded = self.gate_statistics[gate] + load_slip_percent = (rounded['load_delta'] / rounded['load_distance']) * 100 if rounded['load_distance'] != 0. else 0. + unload_slip_percent = (rounded['unload_delta'] / rounded['unload_distance']) * 100 if rounded['unload_distance'] != 0. else 0. + quality = rounded['quality'] + # Give the gate a reliability grading based on "quality" which is based on slippage + if t == 'percentage': + status = '%s%%' % min(100, round(quality * 100, 1)) if quality >= 0 else "n/a" + elif quality < 0: + status = UI_EMOTICONS[0] if t == 'emoticon' else "n/a" + elif quality >= 0.985: + status = UI_EMOTICONS[1] if t == 'emoticon' else "Perfect" + elif quality >= 0.965: + status = UI_EMOTICONS[2] if t == 'emoticon' else "Great" + elif quality >= 0.95: + status = UI_EMOTICONS[3] if t == 'emoticon' else "Good" + elif quality >= 0.925: + status = UI_EMOTICONS[4] if t == 'emoticon' else "Marginal" + elif quality >= 0.90: + status = UI_EMOTICONS[5] if t == 'emoticon' else "Degraded" + elif quality >= 0.85: + status = UI_EMOTICONS[6] if t == 'emoticon' else "Poor" + else: + status = UI_EMOTICONS[7] if t == 'emoticon' else "Terrible" + msg += "%d:%s" % (gate, status) + msg += ", " if gate < (self.num_gates - 1) else "" + dbg += "\nGate %d: " % gate + dbg += "Load: (monitored: %.1fmm slippage: %.1f%%)" % (rounded['load_distance'], load_slip_percent) + dbg += "; Unload: (monitored: %.1fmm slippage: %.1f%%)" % (rounded['unload_distance'], unload_slip_percent) + dbg += "; Failures: (load: %d unload: %d pauses: %d)" % (rounded['load_failures'], rounded['unload_failures'], rounded['pauses']) + dbg += "; Quality: %.1f%%" % ((rounded['quality'] * 100.) if rounded['quality'] >= 0. else 0.) + return msg, dbg + + def _persist_gate_statistics(self): + for gate in range(self.num_gates): + self.save_variable("%s%d" % (self.VARS_MMU_GATE_STATISTICS_PREFIX, gate), self.gate_statistics[gate]) + + # Also a good place to update the persisted calibrated clog length (for auto mode) + if self.has_encoder(): + mode = self.sync_feedback_manager.flowguard_encoder_mode + if mode == self.encoder_sensor.RUNOUT_AUTOMATIC: + cdl = self.encoder_sensor.get_clog_detection_length() + self.calibration_manager.update_clog_detection_length(round(cdl, 1)) + + self.write_variables() + + def _persist_swap_statistics(self): + self.statistics = {key: round(value, 2) if isinstance(value, float) else value for key, value in self.statistics.items()} + self.save_variable(self.VARS_MMU_SWAP_STATISTICS, self.statistics, write=True) + + def _persist_counters(self): + self.save_variable(self.VARS_MMU_COUNTERS, self.counters, write=True) + + + def format_help(self, msg, supplement=None): + """ + Format a help message and optional supplement into a nicely aligned block. + + The input `msg` is expected to be multi-line with the first line containing + either "command: description" or just a single heading line. Subsequent + lines may contain parameter definitions in the form "name = value". + + This function: + - Keeps the heading (and highlights the command using UI markers "{5}" / "{6}"). + - Aligns parameter names into a column (minimum width 10). + - Prefixes parameter lines with a cascade/UI marker using `UI_CASCADE`. + - Uses `UI_SPACE` as the fill character when padding parameter names. + - Optionally appends a supplement block (if provided) wrapped with UI markers. + + Args: + msg: The main help message (multi-line). + supplement: Optional supplemental text (multi-line) appended after the main block. + + Returns: + The formatted help string. + """ + if not msg: + return "" + + lines = msg.splitlines() + if not lines: + return msg + + # Format the heading (first line). If the heading contains ":", split into + # command and description and wrap the command in UI markers. + first_line = lines[0].rstrip() + if ":" in first_line: + cmd, helpstr = first_line.split(":", 1) + formatted_help = "{5}" + cmd.strip() + "{6} : " + helpstr.strip() + else: + formatted_help = first_line + + # Compute parameter name column width: minimum 10, else longest name+1. + param_lines = [ln for ln in lines[1:] if "=" in ln] + def param_name_length(ln): + name = ln.split("=", 1)[0].strip() + return len(name) + 1 + + param_width = max(10, max((param_name_length(ln) for ln in param_lines), default=0)) + + # Build formatted parameter lines + formatted_params: list[str] = [] + for ln in lines[1:]: + if "=" in ln: + key, value = ln.split("=", 1) + key_str = key.strip() + value_str = value.strip() + padded_key = key_str.ljust(param_width, UI_SPACE) + padded = f"{padded_key}= {value_str}" + formatted_line = f"{{4}}{UI_CASCADE} {padded}{{0}}" + else: + formatted_line = f"{{4}}{UI_CASCADE} {ln.rstrip()}{{0}}" + formatted_params.append(formatted_line) + + # Handle supplement if provided + formatted_supplement = "" + if supplement is not None: + supp_lines = supplement.splitlines() + if supp_lines: + first = supp_lines[0].strip() + formatted_supplement = "{3}{5}" + first + "{6}" + if len(supp_lines) > 1: + formatted_supplement += "\n" + "\n".join(line.rstrip() for line in supp_lines[1:]) + formatted_supplement += "{0}" + + main_block = "\n".join([formatted_help] + formatted_params) if formatted_params else formatted_help + return main_block + (("\n" + formatted_supplement) if formatted_supplement else "") + +# def format_help(self, msg, supplement=None): +# lines = msg.splitlines() +# if not lines: +# return msg +# +# first_line = lines[0] +# if ":" in first_line: +# cmd, helpstr = first_line.split(":", 1) +# formatted_help = "{5}%s{6}:%s" % (cmd.strip(), helpstr) +# else: +# formatted_help = first_line +# +# param_width = max(10, max((len(line.split("=", 1)[0].strip()) + 1 for line in lines[1:] if "=" in line), default=0)) +# formatted_params = [] +# for line in lines[1:]: +# if "=" in line: +# key, value = line.split("=", 1) +# padded = key.strip().ljust(param_width, UI_SPACE) + "= " + value.strip() +# formatted_line = "{4}%s %s{0}" % (UI_CASCADE, padded) +# else: +# formatted_line = "{4}%s %s{0}" % (UI_CASCADE, line) +# formatted_params.append(formatted_line) +# +# formatted_supplement = "" +# if supplement is not None: +# lines = supplement.splitlines() +# formatted_supplement = "{3}{5}%s{6}" % lines[0] +# formatted_supplement += lines[1:] +# formatted_supplement += "{0}" +# +# return "\n".join([formatted_help] + formatted_params) + formatted_supplement + + def _color_message(self, msg): + try: + html_msg = msg.format( + '', # {0} COLOR OFF + '', # {1} COLOR GREY + '', # {2} COLOR RED + '', # {3} COLOR GREEN + '', # {4} COLOR CYAN + '', # {5} BOLD ON + '' # {6} BOLD OFF + ) + except (IndexError, KeyError, ValueError) as e: + html_msg = msg + + msg = re.sub(r'\{\d\}', '', msg) # Remove numbered placeholders for plain msg + if self.serious: + html_msg = msg + return html_msg, msg + + def log_to_file(self, msg, prefix='> '): + msg = "%s%s" % (prefix, msg) + if self.mmu_logger: + self.mmu_logger.log(msg) + + def log_error(self, msg, color=False): + html_msg, msg = self._color_message(msg) if color else (msg, msg) + if self.mmu_logger: + self.mmu_logger.log(msg) + self.gcode.respond_raw("!! %s" % html_msg) + + def log_warning(self, msg): + self.log_always("{2}%s{0}" % msg, color=True) + + def log_always(self, msg, color=False): + html_msg, msg = self._color_message(msg) if color else (msg, msg) + if self.mmu_logger: + self.mmu_logger.log(msg) + self.gcode.respond_info(html_msg) + + def log_info(self, msg, color=False): + html_msg, msg = self._color_message(msg) if color else (msg, msg) + if self.mmu_logger and self.log_file_level > 0: + self.mmu_logger.log(msg) + if self.log_level > 0: + self.gcode.respond_info(html_msg) + + def log_debug(self, msg): + msg = "%s DEBUG: %s" % (UI_SEPARATOR, msg) + if self.mmu_logger and self.log_file_level > 1: + self.mmu_logger.log(msg) + if self.log_level > 1: + self.gcode.respond_info(msg) + + def log_trace(self, msg): + msg = "%s %s TRACE: %s" % (UI_SEPARATOR, UI_SEPARATOR, msg) + if self.mmu_logger and self.log_file_level > 2: + self.mmu_logger.log(msg) + if self.log_level > 2: + self.gcode.respond_info(msg) + + def log_stepper(self, msg): + msg = "%s %s %s STEPPER: %s" % (UI_SEPARATOR, UI_SEPARATOR, UI_SEPARATOR, msg) + if self.mmu_logger and self.log_file_level > 3: + self.mmu_logger.log(msg) + if self.log_level > 3: + self.gcode.respond_info(msg) + + def log_enabled(self, level): + return (self.mmu_logger and self.log_file_level >= level) or self.log_level >= level + + # Fun visual display of MMU state + def _display_visual_state(self, silent=False): + if not silent and self.log_visual and not self.calibrating: + visual_str = self._state_to_string() + self.log_always(visual_str, color=True) + + def _state_to_string(self, direction=None): + arrow = "<" if self.filament_direction == self.DIRECTION_UNLOAD else ">" + space = "." + home = "|" + gs = "(g)" # SENSOR_GATE or SENSOR_GEAR_PREFIX + es = "(e)" # SENSOR_EXTRUDER + ts = "(t)" # SENSOR_TOOLHEAD + past = lambda pos: arrow if self.filament_pos >= pos else space + homed = lambda pos, sensor: (' ',arrow,sensor) if self.filament_pos > pos else (home,space,sensor) if self.filament_pos == pos else (' ',space,sensor) + trig = lambda name, sensor: re.sub(r'[a-zA-Z]', '*', name) if self.sensor_manager.check_sensor(sensor) else name + + t_str = ("[T%s] " % str(self.tool_selected)) if self.tool_selected >= 0 else "BYPASS " if self.tool_selected == self.TOOL_GATE_BYPASS else "[T?] " + g_str = "{}".format(past(self.FILAMENT_POS_UNLOADED)) + lg_str = "{0}{0}".format(past(self.FILAMENT_POS_HOMED_GATE)) if not self.mmu_machine.require_bowden_move else "" + gs_str = "{0}{2} {1}{1}".format(*homed(self.FILAMENT_POS_HOMED_GATE, trig(gs, self.gate_homing_endstop))) if self.gate_homing_endstop in [self.SENSOR_GATE, self.SENSOR_GEAR_PREFIX, self.SENSOR_EXTRUDER_ENTRY] else "" + en_str = " En {0}".format(past(self.FILAMENT_POS_IN_BOWDEN if self.gate_homing_endstop in [self.SENSOR_GATE, self.SENSOR_GEAR_PREFIX, self.SENSOR_EXTRUDER_ENTRY] else self.FILAMENT_POS_START_BOWDEN)) if self.has_encoder() else "" + bowden1 = "{0}{0}{0}{0}".format(past(self.FILAMENT_POS_IN_BOWDEN)) if self.mmu_machine.require_bowden_move else "" + bowden2 = "{0}{0}{0}{0}".format(past(self.FILAMENT_POS_END_BOWDEN)) if self.mmu_machine.require_bowden_move else "" + es_str = "{0}{2} {1}{1}".format(*homed(self.FILAMENT_POS_HOMED_ENTRY, trig(es, self.SENSOR_EXTRUDER_ENTRY))) if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY) and self.mmu_machine.require_bowden_move else "" + ex_str = "{0}[{2} {1}{1}".format(*homed(self.FILAMENT_POS_HOMED_EXTRUDER, "Ex")) + ts_str = "{0}{2} {1}".format(*homed(self.FILAMENT_POS_HOMED_TS, trig(ts, self.SENSOR_TOOLHEAD))) if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD) else "" + nz_str = "{} Nz]".format(past(self.FILAMENT_POS_LOADED)) + summary = " {5}{4}LOADED{0}{6}" if self.filament_pos == self.FILAMENT_POS_LOADED else " {5}{4}UNLOADED{0}{6}" if self.filament_pos == self.FILAMENT_POS_UNLOADED else " {5}{2}UNKNOWN{0}{6}" if self.filament_pos == self.FILAMENT_POS_UNKNOWN else "" + counter = " {5}%.1fmm{6}%s" % (self._get_filament_position(), " {1}(e:%.1fmm){0}" % self.get_encoder_distance(dwell=None) if self.has_encoder() and self.encoder_move_validation else "") + visual = "".join((t_str, g_str, lg_str, gs_str, en_str, bowden1, bowden2, es_str, ex_str, ts_str, nz_str, summary, counter)) + return visual + + +### LOGGING AND STATISTICS FUNCTIONS GCODE FUNCTIONS ############################# + + cmd_MMU_STATS_help = "Dump and optionally reset the MMU statistics" + def cmd_MMU_STATS(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + counter = gcmd.get('COUNTER', None) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + total = bool(gcmd.get_int('TOTAL', 0, minval=0, maxval=1)) + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + showcounts = bool(gcmd.get_int('SHOWCOUNTS', 0, minval=0, maxval=1)) + + if counter: + counter = counter.strip() + delete = bool(gcmd.get_int('DELETE', 0, minval=0, maxval=1)) + limit = gcmd.get_int('LIMIT', 0, minval=-1) + incr = gcmd.get_int('INCR', 0, minval=1) + quiet = True + if delete: + _ = self.counters.pop(counter, None) + elif reset: + if counter in self.counters: + self.counters[counter]['count'] = 0 + elif not limit == 0: + if counter not in self.counters: + self.counters[counter] = {'count': 0} + warning = gcmd.get('WARNING', self.counters[counter].get('warning', "")) + pause = bool(gcmd.get_int('PAUSE', self.counters[counter].get('pause', 0), minval=0, maxval=1)) + self.counters[counter].update({'limit': limit, 'warning': warning, 'pause': pause}) + elif incr: + if counter in self.counters: + metric = self.counters[counter] + metric['count'] += incr + if metric['limit'] >= 0 and metric['count'] > metric['limit']: + warn = "Warning: %s" % metric.get('warning', "") + msg = "Count %s (%d) above limit %d" % (counter, metric['count'], metric['limit']) + msg += "\nUse 'MMU_STATS COUNTER=%s RESET=1' to reset" % counter + if metric.get('pause', False): + self.handle_mmu_error("%s\n%s" % (warn, msg)) + else: + self.log_error(warn) + self.log_always(msg) + else: + self.counters[counter] = {'count': 0, 'limit': -1, 'warning': ""} + self._persist_counters() + elif reset: + self._reset_statistics() + self._persist_swap_statistics() + self._persist_gate_statistics() + if not quiet: + self._dump_statistics(force_log=True, total=True) + return + + if not quiet: + self._dump_statistics(force_log=True, total=total or detail, job=True, gate=True, detail=detail, showcounts=showcounts) + + cmd_MMU_STATUS_help = "Complete dump of current MMU state and important configuration" + def cmd_MMU_STATUS(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + config = gcmd.get_int('SHOWCONFIG', 0, minval=0, maxval=1) + detail = gcmd.get_int('DETAIL', 0, minval=0, maxval=1) + on_off = lambda x: "ON" if x else "OFF" + + msg = "MMU: Happy Hare %s running %s v%s" % (self._fversion(self.config_version), self.mmu_machine.mmu_vendor, self.mmu_machine.mmu_version_string) + msg += " with %d gates" % self.num_gates + msg += (" over %d units" % self.mmu_machine.num_units) if self.mmu_machine.num_units > 1 else "" + msg += " (%s) " % ("DISABLED" if not self.is_enabled else "PAUSED" if self.is_mmu_paused() else "OPERATIONAL") + msg += self.selector.get_mmu_status_config() + if self.has_encoder(): + msg += ". Encoder reads %.1fmm" % self.get_encoder_distance() + msg += "\nPrint state is %s" % self.print_state.upper() + msg += ". Tool %s selected on gate %s%s" % (self.selected_tool_string(), self.selected_gate_string(), self.selected_unit_string()) + msg += ". Toolhead position saved" if self.saved_toolhead_operation else "" + msg += "\nMMU gear stepper at %d%% current and is %s to extruder" % (self.gear_percentage_run_current, "SYNCED" if self.mmu_toolhead.is_gear_synced_to_extruder() else "not synced") + if self._standalone_sync: + msg += ". Standalone sync mode is ENABLED" + if self.sync_feedback_manager.is_enabled(): + msg += "\nSync feedback indicates filament in bowden is: %s" % self.sync_feedback_manager.get_sync_feedback_string(detail=True).upper() + if not self.sync_feedback_manager.is_active(): + msg += " (not currently active)" + else: + msg += "\nSync feedback is disabled" + + if config: + self.calibrated_bowden_length = self.calibration_manager.get_bowden_length() # Temp scalar pulled from list for _f_calc() + msg += "\n\nLOAD SEQUENCE:" + + # Gate loading + msg += "\n- Filament loads into gate by homing a maximum of %s to %s" % (self._f_calc("gate_homing_max"), self._gate_homing_string()) + + # Bowden loading + if self.mmu_machine.require_bowden_move: + if self._must_buffer_extruder_homing(): + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_ENTRY: + msg += "\n- Bowden is loaded with a fast%s %s move" % (" CORRECTED" if self.bowden_apply_correction else "", self._f_calc("calibrated_bowden_length - toolhead_entry_to_extruder - extruder_homing_buffer")) + else: + msg += "\n- Bowden is loaded with a fast%s %s move" % (" CORRECTED" if self.bowden_apply_correction else "", self._f_calc("calibrated_bowden_length - extruder_homing_buffer")) + else: + msg += "\n- Bowden is loaded with a full fast%s %s move" % (" CORRECTED" if self.bowden_apply_correction else "", self._f_calc("calibrated_bowden_length")) + else: + msg += "\n- No fast bowden move is required" + + # Extruder homing + if self._must_home_to_extruder(): + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_COLLISION: + msg += ", then homes a maximum of %s to extruder using COLLISION detection (at %d%% current)" % (self._f_calc("extruder_homing_max"), self.extruder_collision_homing_current) + elif self.extruder_homing_endstop == self.SENSOR_GEAR_TOUCH: + msg += ", then homes a maxium of %s to extruder using 'touch' (stallguard) detection" % self._f_calc("extruder_homing_max") + else: + msg += ", then homes a maximum of %s to %s sensor" % (self._f_calc("extruder_homing_max"), self.extruder_homing_endstop.upper()) + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_ENTRY: + msg += " and then moves %s to extruder extrance" % self._f_calc("toolhead_entry_to_extruder") + else: + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_NONE and not self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + msg += ". WARNING: no extruder homing is performed - extruder loading cannot be precise" + else: + msg += ", no extruder homing is necessary" + + # Extruder loading + if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + msg += "\n- Extruder (synced) loads by homing a maximum of %s to TOOLHEAD sensor before moving the last %s to the nozzle" % (self._f_calc("toolhead_homing_max"), self._f_calc("toolhead_sensor_to_nozzle - toolhead_residual_filament - toolhead_ooze_reduction - toolchange_retract - filament_remaining")) + else: + msg += "\n- Extruder (synced) loads by moving %s to the nozzle" % self._f_calc("toolhead_extruder_to_nozzle - toolhead_residual_filament - toolhead_ooze_reduction - toolchange_retract - filament_remaining") + + # Purging + if self.force_purge_standalone: + if self.purge_macro: + msg += "\n- Purging is always managed by Happy Hare using '%s' macro with extruder purging current of %d%%" % ( + self.purge_macro, self.extruder_purge_current) + else: + msg += "\n- No purging is performed!" + else: + if self.purge_macro: + msg += "\n- Purging is managed by slicer when printing. Otherwise by Happy Hare using '%s' macro with extruder purging current of %d%% when not printing" % ( + self.purge_macro, self.extruder_purge_current) + else: + msg += "\n- Purging is managed by slicer only when printing" + + # Tightening + has_tension = self.sensor_manager.has_sensor(self.SENSOR_TENSION) + has_compression = self.sensor_manager.has_sensor(self.SENSOR_COMPRESSION) + has_proportional = self.sensor_manager.has_sensor(self.SENSOR_PROPORTIONAL) + if ( + self.toolhead_post_load_tighten + and not self.sync_to_extruder + and self._can_use_encoder() + and self.sync_feedback_manager.flowguard_encoder_mode + ): + msg += "\n- Filament in bowden is tightened by %.1fmm (%d%% of clog detection length) at reduced gear current to prevent false clog detection" % (min(self.encoder_sensor.get_clog_detection_length() * self.toolhead_post_load_tighten / 100, 15), self.toolhead_post_load_tighten) + elif ( + self.toolhead_post_load_tension_adjust + and (self.sync_to_extruder or self.sync_purge) + and (has_tension or has_compression or has_proportional) + and self.sync_feedback_manager.is_enabled() + ): + msg += "\n- Filament in bowden will be adjusted a maximum of %.1fmm to neutralize tension" % (self.sync_feedback_manager.sync_feedback_buffer_range or self.sync_feedback_manager.sync_feedback_buffer_maxrange) + + msg += "\n\nUNLOAD SEQUENCE:" + + # Tip forming + if self.force_form_tip_standalone: + if self.form_tip_macro: + msg += "\n- Tip is always formed by Happy Hare using '%s' macro after initial retract of %s with extruder current of %d%%" % ( + self.form_tip_macro, self._f_calc("toolchange_retract"), self.extruder_form_tip_current) + else: + msg += "\n- No tip forming is performed!" + else: + if self.form_tip_macro: + msg += "\n- Tip is formed by slicer when printing. Otherwise by Happy Hare using '%s' macro after initial retract of %s with extruder current of %d%%" % ( + self.form_tip_macro, self._f_calc("toolchange_retract"), self.extruder_form_tip_current) + else: + msg += "\n- Tip is formed by slicer only when printing" + + # Extruder unloading + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "\n- Extruder (synced) unloads by reverse homing a maximum of %s to EXTRUDER sensor" % self._f_calc("toolhead_entry_to_extruder + toolhead_extruder_to_nozzle - toolhead_residual_filament - toolhead_ooze_reduction - toolchange_retract + toolhead_unload_safety_margin") + elif self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + msg += "\n- Extruder (optionally synced) unloads by reverse homing a maximum %s to TOOLHEAD sensor" % self._f_calc("toolhead_sensor_to_nozzle - toolhead_residual_filament - toolhead_ooze_reduction - toolchange_retract + toolhead_unload_safety_margin") + msg += ", then unloads by moving %s to exit extruder" % self._f_calc("toolhead_extruder_to_nozzle - toolhead_sensor_to_nozzle + toolhead_unload_safety_margin") + else: + msg += "\n- Extruder (optionally synced) unloads by moving %s less tip-cutting reported park position to exit extruder" % self._f_calc("toolhead_extruder_to_nozzle + toolhead_unload_safety_margin") + + # Bowden unloading + if self.mmu_machine.require_bowden_move: + if self.has_encoder() and self.bowden_pre_unload_test and not self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "\n- Bowden is unloaded with a short %s validation move before %s fast move" % (self._f_calc("encoder_move_step_size"), self._f_calc("calibrated_bowden_length - gate_unload_buffer - encoder_move_step_size")) + else: + msg += "\n- Bowden is unloaded with a fast %s move" % self._f_calc("calibrated_bowden_length - gate_unload_buffer") + else: + msg += "\n- No fast bowden move is required" + + # Gate parking + msg += "\n- Filament is stored by homing a maximum of %s to %s and parking %s in the gate\n" % (self._f_calc("gate_homing_max"), self._gate_homing_string(), self._f_calc("gate_parking_distance")) + + if self.sync_form_tip or self.sync_purge or self.sync_to_extruder: + msg += "\nGear and Extruder steppers are synchronized during: " + m = [] + if self.sync_to_extruder: + m.append("Print (at %d%% current %s sync feedback)" % (self.sync_gear_current, "with" if self.sync_feedback_manager.is_enabled() else "without")) + if self.sync_form_tip: + m.append("Tip forming") + if self.sync_purge: + m.append("Purging") + msg += ", ".join(m) + + if hasattr(self.selector, 'use_touch_move'): + msg += "\nSelector touch (stallguard) is %s - blocked gate recovery %s possible" % (("ENABLED", "is") if self.selector.use_touch_move() else ("DISABLED", "is not")) + if self.has_encoder(): + msg += "\nMMU has an encoder. Non essential move validation is %s" % ("ENABLED" if self._can_use_encoder() else "DISABLED") + msg += "\nFlowGuard detection is %s" % ("AUTOMATIC" if self.sync_feedback_manager.flowguard_encoder_mode == self.encoder_sensor.RUNOUT_AUTOMATIC else "ENABLED" if self.sync_feedback_manager.flowguard_encoder_mode == self.encoder_sensor.RUNOUT_STATIC else "DISABLED") + msg += " (%.1fmm runout)" % self.encoder_sensor.get_clog_detection_length() + msg += ", EndlessSpool is %s" % ("ENABLED" if self.endless_spool_enabled else "DISABLED") + else: + msg += "\nMMU does not have an encoder - move validation or clog detection is not possible" + msg += "\nSpoolMan is %s" % ("ENABLED (pulling gate map)" if self.spoolman_support == self.SPOOLMAN_PULL else "ENABLED (push gate map)" if self.spoolman_support == self.SPOOLMAN_PUSH else "ENABLED" if self.spoolman_support == self.SPOOLMAN_READONLY else "DISABLED") + msg += "\nSensors: " + sensors = self.sensor_manager.get_all_sensors(inactive=True) + for name, state in sensors.items(): + msg += "%s (%s), " % (name.upper(), "Disabled" if state is None else ("Detected" if state is True else "Empty")) + msg += "\nLogging: Console %d(%s)" % (self.log_level, self.LOG_LEVELS[self.log_level]) + + msg += ", Logfile %d(%s)" % (self.log_file_level, self.LOG_LEVELS[self.log_file_level]) + msg += ", Visual %d(%s)" % (self.log_visual, on_off(self.log_visual)) + msg += ", Statistics %d(%s)" % (self.log_statistics, on_off(self.log_statistics)) + + if not detail: + msg += "\n\nFor details on TTG and EndlessSpool groups add 'DETAIL=1'" + if not config: + msg += ", for configuration add 'SHOWCONFIG=1'" + + msg += "\n\n%s" % self._mmu_visual_to_string() + msg += "\n%s" % self._state_to_string() + + if detail: + msg += "\n\n%s" % self._ttg_map_to_string() + if self.endless_spool_enabled: + msg += "\n\n%s" % self._es_groups_to_string() + msg += "\n\n%s" % self._gate_map_to_string() + + if self.reason_for_pause: + msg += "\n\n{2}MMU is paused because:\n%s{0}" % self.reason_for_pause + + self.log_always(msg, color=True) + + # Always warn if not fully calibrated or needs recovery + self.report_necessary_recovery(use_autotune=False) + + def _f_calc(self, formula): + format_var = lambda p: p + ':' + "%.1f" % vars(self).get(p.lower()) + terms = re.split('(\+|\-)', formula) + result = eval(formula, {}, vars(self)) + formatted_formula = "%.1fmm (" % result + for term in terms: + term = term.strip() + if term in ('+', '-'): + formatted_formula += " " + term + " " + elif len(terms) > 1: + formatted_formula += format_var(term) + else: + formatted_formula += term + formatted_formula += ")" + return formatted_formula + + cmd_MMU_SENSORS_help = "Query state of sensors fitted to mmu" + def cmd_MMU_SENSORS(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + + eventtime = self.reactor.monotonic() + msg = self.sensor_manager.get_sensor_summary(detail=detail) + if self.has_encoder(): + msg += self._get_encoder_summary(detail=detail) + self.log_always(msg) + + def _get_encoder_summary(self, detail=False): # TODO move to mmu_sensor_manager? + status = self.encoder_sensor.get_status(0) + msg = "Encoder position: %.1f" % status['encoder_pos'] + if detail: + msg += "\n- FlowGuard/Runout: %s" % ("Active" if status['enabled'] else "Inactive") + clog = "Automatic" if status['detection_mode'] == 2 else "On" if status['detection_mode'] == 1 else "Off" + msg += "\n- FlowGuard mode: %s (Detection length: %.1f)" % (clog, status['detection_length']) + msg += "\n- Remaining headroom before trigger: %.1f (min: %.1f)" % (status['headroom'], status['min_headroom']) + msg += "\n- Flowrate: %d %%" % status['flow_rate'] + return msg + + def motors_onoff(self, on=False, motor="all"): + stepper_enable = self.printer.lookup_object('stepper_enable') + steppers = self.gear_rail.steppers if motor == "gears" else [self.gear_rail.steppers[0]] if self.gear_rail.steppers else [] + if on: + if motor in ["all", "gear", "gears"]: + for stepper in steppers: + se = stepper_enable.lookup_enable(stepper.get_name()) + se.motor_enable(self.mmu_toolhead.get_last_move_time()) + if motor in ["all", "selector"]: + self.selector.enable_motors() + self.selector.restore_gate(self.gate_selected) + self.selector.filament_hold_move() # Aka selector move position + else: + if motor in ["all", "gear", "gears"]: + self.mmu_toolhead.unsync() + for stepper in steppers: + se = stepper_enable.lookup_enable(stepper.get_name()) + se.motor_disable(self.mmu_toolhead.get_last_move_time()) + if motor in ["all", "selector"]: + self.selector.restore_gate(self.TOOL_GATE_UNKNOWN) + self.selector.disable_motors() + + +### SERVO AND MOTOR GCODE FUNCTIONS ############################################## + + # This command will loose sync state + cmd_MMU_MOTORS_OFF_help = "Turn off all MMU motors and servos" + def cmd_MMU_MOTORS_OFF(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self.sync_gear_to_extruder(False, force_grip=True) + self.motors_onoff(on=False) + + cmd_MMU_MOTORS_ON_help = "Turn on all MMU motors and servos" + def cmd_MMU_MOTORS_ON(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self.motors_onoff(on=True) + self.reset_sync_gear_to_extruder(False) + + cmd_MMU_TEST_BUZZ_MOTOR_help = "Simple buzz the selected motor (default gear) for setup testing" + def cmd_MMU_TEST_BUZZ_MOTOR(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + motor = gcmd.get('MOTOR', "gear") + + with self.wrap_sync_gear_to_extruder(): + if motor == "gear": + found = self.buzz_gear_motor() + if found is not None: + self.log_info("Filament %s by gear motor buzz" % ("detected" if found else "not detected")) + elif motor == "gears": + try: + for gate in range(self.num_gates): + self.mmu_toolhead.select_gear_stepper(gate) + found = self.buzz_gear_motor() + if found is not None: + self.log_info("Filament %s in gate %d by gear motor buzz" % ("detected" if found else "not detected", gate)) + finally: + self.mmu_toolhead.select_gear_stepper(self.gate_selected) + elif not self.selector.buzz_motor(motor): + raise gcmd.error("Motor '%s' not known" % motor) + + cmd_MMU_SYNC_GEAR_MOTOR_help = "Sync the MMU gear motor to the extruder stepper" + cmd_MMU_SYNC_GEAR_MOTOR_param_help = ( + "MMU_SYNC_GEAR_MOTOR: %s\n" % cmd_MMU_SYNC_GEAR_MOTOR_help + + "SYNC = [0|1] Specify whether to force extruder/mmu syncing out of a print" + + "(no parameters will default SYNC=1)" + ) + def cmd_MMU_SYNC_GEAR_MOTOR(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.log_always(self.format_help(self.cmd_MMU_SYNC_GEAR_MOTOR_param_help), color=True) + return + + if self.check_if_bypass(unloaded_ok=False): return + if self.check_if_not_homed(): return + sync = bool(gcmd.get_int('SYNC', 1, minval=0, maxval=1)) + + if self.check_if_always_gripped(): return + + if not self.is_in_print() and self._standalone_sync != sync: + self._standalone_sync = sync # Make sticky if not in a print + if self._standalone_sync: + self.log_info("MMU gear stepper will be synced with extruder whenever filament is in extruder") + else: + self.log_info("MMU gear stepper is unsynced from extruder") + + if sync and self.filament_pos < self.FILAMENT_POS_EXTRUDER_ENTRY: + self.log_warning("Will temporarily sync, but filament position does not indicate in extruder!\nUse 'MMU_RECOVER' to correct the filament position.") + + self.reset_sync_gear_to_extruder(sync, force_grip=True, skip_extruder_check=True) + + +######################### +# CALIBRATION FUNCTIONS # +######################### + + def _sample_stats(self, values): + mean = stdev = vmin = vmax = 0. + if values: + mean = sum(values) / len(values) + diff2 = [( v - mean )**2 for v in values] + stdev = math.sqrt( sum(diff2) / max((len(values) - 1), 1)) + vmin = min(values) + vmax = max(values) + return {'mean': mean, 'stdev': stdev, 'min': vmin, 'max': vmax, 'range': vmax - vmin} + + # Filament is assumed to be at the extruder and will be at extruder again when complete + def _probe_toolhead(self, cold_temp=70, probe_depth=100, sensor_homing=80): + # Ensure extruder is COLD + self.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=0" % self.extruder_name) + current_temp = self.printer.lookup_object(self.extruder_name).get_status(0)['temperature'] + if current_temp > cold_temp: + self.log_always("Waiting for extruder to cool") + self.gcode.run_script_from_command("TEMPERATURE_WAIT SENSOR=%s MINIMUM=0 MAXIMUM=%d" % (self.extruder_name, cold_temp)) + + # Enable the extruder stepper + stepper_enable = self.printer.lookup_object('stepper_enable') + ge = stepper_enable.lookup_enable(self.mmu_extruder_stepper.stepper.get_name()) + ge.motor_enable(self.toolhead.get_last_move_time()) + + # Reliably force filament to the nozzle + self.selector.filament_drive() + actual,fhomed,_,_ = self.trace_filament_move("Homing to toolhead sensor", self.toolhead_homing_max, motor="gear+extruder", homing_move=1, endstop_name=self.SENSOR_TOOLHEAD) + if not fhomed: + raise MmuError("Failed to reach toolhead sensor after moving %.1fmm" % self.toolhead_homing_max) + self.selector.filament_release() + actual,_,_,_ = self.trace_filament_move("Forcing filament to nozzle", probe_depth, motor="extruder") + + # Measure 'toolhead_sensor_to_nozzle' + self.selector.filament_drive() + actual,fhomed,_,_ = self.trace_filament_move("Reverse homing off toolhead sensor", -probe_depth, motor="gear+extruder", homing_move=-1, endstop_name=self.SENSOR_TOOLHEAD) + if fhomed: + toolhead_sensor_to_nozzle = -actual + self.log_always("Measured toolhead_sensor_to_nozzle: %.1f" % toolhead_sensor_to_nozzle) + else: + raise MmuError("Failed to reverse home to toolhead sensor") + + # Move to extruder extrance again + self.selector.filament_release() + actual,_,_,_ = self.trace_filament_move("Moving to extruder entrance", -(probe_depth - toolhead_sensor_to_nozzle), motor="extruder") + + # Measure 'toolhead_extruder_to_nozzle' + self.selector.filament_drive() + actual,fhomed,_,_ = self.trace_filament_move("Homing to toolhead sensor", self.toolhead_homing_max, motor="gear+extruder", homing_move=1, endstop_name=self.SENSOR_TOOLHEAD) + if fhomed: + toolhead_extruder_to_nozzle = actual + toolhead_sensor_to_nozzle + self.log_always("Measured toolhead_extruder_to_nozzle: %.1f" % toolhead_extruder_to_nozzle) + else: + raise MmuError("Failed to home to toolhead sensor") + + toolhead_entry_to_extruder = 0. + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + # Retract clear of extruder sensor and then home in "extrude" direction + actual,fhomed,_,_ = self.trace_filament_move("Reverse homing off extruder entry sensor", -(sensor_homing + toolhead_extruder_to_nozzle - toolhead_sensor_to_nozzle), motor="gear+extruder", homing_move=-1, endstop_name=self.SENSOR_EXTRUDER_ENTRY) + actual,_,_,_ = self.trace_filament_move("Moving before extruder entry sensor", -20, motor="gear+extruder") + actual,fhomed,_,_ = self.trace_filament_move("Homing to extruder entry sensor", 40, motor="gear+extruder", homing_move=1, endstop_name=self.SENSOR_EXTRUDER_ENTRY) + + # Measure to toolhead sensor and thus derive 'toolhead_entry_to_extruder' + if fhomed: + actual,fhomed,_,_ = self.trace_filament_move("Homing to toolhead sensor", sensor_homing, motor="gear+extruder", homing_move=1, endstop_name=self.SENSOR_TOOLHEAD) + if fhomed: + toolhead_entry_to_extruder = actual - (toolhead_extruder_to_nozzle - toolhead_sensor_to_nozzle) + self.log_always("Measured toolhead_entry_to_extruder: %.1f" % toolhead_entry_to_extruder) + else: + raise MmuError("Failed to reverse home to toolhead sensor") + + # Unload and re-park filament + self.selector.filament_release() + actual,_,_,_ = self.trace_filament_move("Moving to extruder entrance", -sensor_homing, motor="extruder") + + return toolhead_extruder_to_nozzle, toolhead_sensor_to_nozzle, toolhead_entry_to_extruder + + +### CALIBRATION GCODE COMMANDS ################################################### + + cmd_MMU_CALIBRATE_GEAR_help = "Calibration routine for gear stepper rotational distance" + def cmd_MMU_CALIBRATE_GEAR(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_gate_not_valid(): return + length = gcmd.get_float('LENGTH', 100., above=50.) + measured = gcmd.get_float('MEASURED', -1, above=0.) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + reset = gcmd.get_int('RESET', 0, minval=0, maxval=1) + gate = self.gate_selected if self.gate_selected >= 0 else 0 + + with self.wrap_sync_gear_to_extruder(): + if reset: + self.set_gear_rotation_distance(self.default_rotation_distance) + self.calibration_manager.update_gear_rd(-1) + return + + if measured > 0: + current_rd = self.gear_rail.steppers[0].get_rotation_distance()[0] + new_rd = round(current_rd * measured / length, 4) + self.log_always("MMU gear stepper 'rotation_distance' calculated to be %.4f (currently: %.4f)" % (new_rd, current_rd)) + if save: + self.set_gear_rotation_distance(new_rd) + self.calibration_manager.update_gear_rd(new_rd, console_msg=True) + return + + raise gcmd.error("Must specify 'MEASURED=' and optionally 'LENGTH='") + + # Start: Assumes filament is loaded through encoder + # End: Does not eject filament at end (filament same as start) + cmd_MMU_CALIBRATE_ENCODER_help = "Calibration routine for the MMU encoder" + def cmd_MMU_CALIBRATE_ENCODER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self._check_has_encoder(): return + if self.check_if_bypass(): return + if self.check_if_not_calibrated(self.CALIBRATED_GEAR_0, check_gates=[self.gate_selected]): return + + length = gcmd.get_float('LENGTH', 400., above=0.) + repeats = gcmd.get_int('REPEATS', 3, minval=1, maxval=10) + speed = gcmd.get_float('SPEED', self.gear_from_buffer_speed, minval=10.) + accel = gcmd.get_float('ACCEL', self.gear_from_buffer_accel, minval=10.) + min_speed = gcmd.get_float('MINSPEED', speed, above=0.) + max_speed = gcmd.get_float('MAXSPEED', speed, above=0.) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + advance = 60. # Ensure filament is in encoder even if not loaded by user + + try: + with self.wrap_sync_gear_to_extruder(): + with self._require_encoder(): + self.selector.filament_drive() + self.calibrating = True + _,_,measured,_ = self.trace_filament_move("Checking for filament", advance) + if measured < self.encoder_min: + raise MmuError("Filament not detected in encoder. Ensure filament is available and try again") + self._unload_tool() + self.calibration_manager.calibrate_encoder(length, repeats, speed, min_speed, max_speed, accel, save) + _,_,_,_ = self.trace_filament_move("Parking filament", -advance) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + # Calibrated bowden length is always from chosen gate homing point to the entruder gears + # Start: With desired gate selected + # End: Filament will be unloaded + cmd_MMU_CALIBRATE_BOWDEN_help = "Calibration of reference bowden length for selected gate" + def cmd_MMU_CALIBRATE_BOWDEN(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_no_bowden_move(): return + if self.check_if_not_homed(): return + if self.check_if_bypass(): return + if self.check_if_loaded(): return + if self.check_if_gate_not_valid(): return + + repeats = gcmd.get_int('REPEATS', 3, minval=1, maxval=10) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + manual = bool(gcmd.get_int('MANUAL', 0, minval=0, maxval=1)) + collision = bool(gcmd.get_int('COLLISION', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + + if reset: + self.calibration_manager.update_bowden_length(-1, console_msg=True) + return + + if manual: + if self.check_if_not_calibrated(self.CALIBRATED_GEAR_0|self.CALIBRATED_SELECTOR, check_gates=[self.gate_selected]): return + else: + if self.check_if_not_calibrated(self.CALIBRATED_GEAR_0|self.CALIBRATED_ENCODER|self.CALIBRATED_SELECTOR, check_gates=[self.gate_selected]): return + + can_use_sensor = ( + self.extruder_homing_endstop in [ + self.SENSOR_EXTRUDER_ENTRY, + self.SENSOR_COMPRESSION, + self.SENSOR_GEAR_TOUCH + ] and ( + self.sensor_manager.has_sensor(self.extruder_homing_endstop) or + self.gear_rail.is_endstop_virtual(self.extruder_homing_endstop) + ) + ) + can_auto_calibrate = self.has_encoder() or can_use_sensor + + if not can_auto_calibrate and not manual: + self.log_always("No encoder or extruder entry sensor available. Use manual calibration method:\nWith gate selected, manually load filament all the way to the extruder gear\nThen run 'MMU_CALIBRATE_BOWDEN MANUAL=1 BOWDEN_LENGTH=xxx'\nWhere BOWDEN_LENGTH is greater than your real length") + return + + extruder_homing_max = gcmd.get_float('HOMING_MAX', 150, above=0.) + approx_bowden_length = gcmd.get_float('BOWDEN_LENGTH', self.bowden_homing_max if (manual or can_use_sensor) else None, above=0.) + if not approx_bowden_length: + raise gcmd.error("Must specify 'BOWDEN_LENGTH=x' where x is slightly LESS than your estimated bowden length to give room for homing") + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): + self.calibrating = True + if manual: + # Method 1: Manual (reverse homing to gate) method + length = self.calibration_manager.calibrate_bowden_length_manual(approx_bowden_length) + + elif can_use_sensor and not collision: + # Method 2: Automatic one-shot method with homing sensor (BEST) + self._unload_tool() + length = self.calibration_manager.calibrate_bowden_length_sensor(approx_bowden_length) + + elif self.has_encoder(): + # Method 3: Automatic averaging method with encoder and extruder collision. Uses repeats for accuracy + self._unload_tool() + length = self.calibration_manager.calibrate_bowden_length_collision(approx_bowden_length, extruder_homing_max, repeats) + + else: + raise gcmd.error("Invalid configuration or options provided. Perhaps you tried COLLISION=1 without encoder or don't have extruder_homing_endstop set?") + + cdl = None + msg = "Calibrated bowden length is %.1fmm" % length + if self.has_encoder(): + cdl = self.calibration_manager.calc_clog_detection_length(length) + msg += ". Recommended flowguard_encoder_max_motion (clog detection length): %.1fmm" % cdl + self.log_always(msg) + + if save: + self.calibration_manager.update_bowden_length(length, console_msg=True) + if cdl is not None: + self.calibration_manager.update_clog_detection_length(length, force=True) + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + # Start: Will home selector, select gate 0 or required gate + # End: Filament will unload + cmd_MMU_CALIBRATE_GATES_help = "Optional calibration of individual MMU gate" + def cmd_MMU_CALIBRATE_GATES(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_bypass(): return + length = gcmd.get_float('LENGTH', 400., above=0.) + repeats = gcmd.get_int('REPEATS', 3, minval=1, maxval=10) + all_gates = gcmd.get_int('ALL', 0, minval=0, maxval=1) + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + + if gate == -1 and not all_gates: + raise gcmd.error("Must specify 'GATE=' or 'ALL=1' for all gates") + + if reset: + if all_gates: + self.set_gear_rotation_distance(self.default_rotation_distance) + for gate in range(self.num_gates - 1): + self.calibration_manager.update_gear_rd(-1, gate + 1) + else: + self.calibration_manager.update_gear_rd(-1, gate) + return + + if self.check_if_not_calibrated( + self.CALIBRATED_GEAR_0 | self.CALIBRATED_ENCODER | self.CALIBRATED_SELECTOR, + check_gates=[gate] if gate != -1 else None + ): return + + try: + with self.wrap_sync_gear_to_extruder(): + self._unload_tool() + self.calibrating = True + with self._require_encoder(): + if all_gates: + self.log_always("Start the complete calibration of ancillary gates...") + for gate in range(self.num_gates - 1): + self.calibration_manager.calibrate_gate(gate + 1, length, repeats, save=save) + self.log_always("Phew! End of auto gate calibration") + else: + self.calibration_manager.calibrate_gate(gate, length, repeats, save=(save and gate != 0)) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + # Start: Test gate should already be selected + # End: Filament will unload + cmd_MMU_CALIBRATE_TOOLHEAD_help = "Automated measurement of key toolhead parameters" + def cmd_MMU_CALIBRATE_TOOLHEAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_bypass(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_GEAR_0|self.CALIBRATED_ENCODER|self.CALIBRATED_SELECTOR|self.CALIBRATED_BOWDENS, check_gates=[self.gate_selected]): return + if not self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + raise gcmd.error("Sorry this feature requires a toolhead sensor") + clean = gcmd.get_int('CLEAN', 0, minval=0, maxval=1) + dirty = gcmd.get_int('DIRTY', 0, minval=0, maxval=1) + cut = gcmd.get_int('CUT', 0, minval=0, maxval=1) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + line = "-----------------------------------------------\n" + + if not (clean or cut or dirty): + msg = "Reminder - run with this sequence of options:\n" + msg += "1) 'CLEAN=1' with clean extruder for: toolhead_extruder_to_nozzle, toolhead_sensor_to_nozzle (and toolhead_entry_to_extruder)\n" + msg += "2) 'DIRTY=1' with dirty extruder (no not cut tip fragment) for: toolhead_residual_filament (and toolhead_entry_to_extruder)\n" + msg += "3) 'CUT=1' holding blade in for: variable_blade_pos\n" + msg += "Desired gate should be selected but the filament unloaded\n" + msg += "('SAVE=0' to run without persisting results)\n" + msg += "Note: On Type-B MMU's you might experience noise/grinding as movement limits are explored (select bypass or reduce gear stepper current if a problem)\n" + self.log_always(msg) + return + + if cut: + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.form_tip_macro, None) + if gcode_macro is None: + raise gcmd.error("Filament tip forming macro '%s' not found" % self.form_tip_macro) + gcode_vars = self.printer.lookup_object("gcode_macro %s_VARS" % self.form_tip_macro, gcode_macro) + if not ('blade_pos' in gcode_vars.variables and 'retract_length' in gcode_vars.variables): + raise gcmd.error("Filament tip forming macro '%s' does not look like a cutting macro!" % self.form_tip_macro) + + try: + with self.wrap_sync_gear_to_extruder(): + self.calibrating = True + self._initialize_filament_position(dwell=True) + overshoot = self._load_gate(allow_retry=False) + _,_ = self._load_bowden(start_pos=overshoot) + _,_ = self._home_to_extruder(self.extruder_homing_max) + + if cut: + self.log_always("Measuring blade cutter postion (with filament fragment)...") + tetn, tstn, tete = self._probe_toolhead() + # Blade position is the difference between empty and extruder with full cut measurements for sensor to nozzle + vbp = self.toolhead_sensor_to_nozzle - tstn + msg = line + if abs(vbp - self.toolhead_residual_filament) < 5: + self.log_error("Measurements did not make sense. Looks like probing went past the blade pos!\nAre you holding the blade closed or have cut filament in the extruder?") + else: + msg += "Calibration Results (cut tip):\n" + msg += "> variable_blade_pos: %.1f (currently: %.1f)\n" % (vbp, gcode_vars.variables['blade_pos']) + msg += "> variable_retract_length: %.1f-%.1f, recommend: %.1f (currently: %.1f)\n" % (self.toolhead_residual_filament + self.toolchange_retract, vbp, vbp - 5., gcode_vars.variables['retract_length']) + msg += line + self.log_always(msg) + if save: + self.log_always("New calibrated blade_pos and retract_length active until restart. Update mmu_macro_vars.cfg to persist") + gcode_vars.variables['blade_pos'] = vbp + gcode_vars.variables['retract_length'] = vbp - 5. + + elif clean: + self.log_always("Measuring clean toolhead dimensions after cold pull...") + tetn, tstn, tete = self._probe_toolhead() + msg = line + msg += "Calibration Results (clean nozzle):\n" + msg += "> toolhead_extruder_to_nozzle: %.1f (currently: %.1f)\n" % (tetn, self.toolhead_extruder_to_nozzle) + msg += "> toolhead_sensor_to_nozzle: %.1f (currently: %.1f)\n" % (tstn, self.toolhead_sensor_to_nozzle) + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "> toolhead_entry_to_extruder: %.1f (currently: %.1f)\n" % (tete, self.toolhead_entry_to_extruder) + msg += line + self.log_always(msg) + if save: + self.log_always("New toolhead calibration active until restart. Update mmu_parameters.cfg to persist settings") + self.toolhead_extruder_to_nozzle = round(tetn, 1) + self.toolhead_sensor_to_nozzle = round(tstn, 1) + self.toolhead_entry_to_extruder = round(tete, 1) + + elif dirty: + self.log_always("Measuring dirty toolhead dimensions (with filament residue)...") + tetn, tstn, tete = self._probe_toolhead() + # Ooze reduction is the difference between empty and dirty measurements for sensor to nozzle + tor = self.toolhead_sensor_to_nozzle - tstn + msg = line + msg += "Calibration Results (dirty nozzle):\n" + msg += "> toolhead_residual_filament: %.1f (currently: %.1f)\n" % (tor, self.toolhead_residual_filament) + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "> toolhead_entry_to_extruder: %.1f (currently: %.1f)\n" % (tete, self.toolhead_entry_to_extruder) + msg += line + self.log_always(msg) + if save: + self.toolhead_residual_filament = round(tor, 1) + self.toolhead_entry_to_extruder = round(tete, 1) + + # Unload and park filament + _ = self._unload_bowden() + _,_ = self._unload_gate() + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + + # Start: Filament must be loaded in extruder + cmd_MMU_CALIBRATE_PSENSOR_help = "Calibrate analog proprotional sync-feedback sensor" + def cmd_MMU_CALIBRATE_PSENSOR(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + + if not self.sensor_manager.has_sensor(self.SENSOR_PROPORTIONAL): + raise gcmd.error("Proportional (analog sync-feedback) sensor not found\n" + usage) + + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_not_loaded(): return + + SD_THRESHOLD = 0.02 + MAX_MOVE_MULTIPLIER = 1.8 + STEP_SIZE = 2.0 + MOVE_SPEED = 8.0 + + move = gcmd.get_float('MOVE', self.sync_feedback_manager.sync_feedback_buffer_maxrange, minval=1, maxval=100) + steps = math.ceil(move * MAX_MOVE_MULTIPLIER / STEP_SIZE) + + usage = ( + "Ensure your sensor is configured by setting sync_feedback_analog_pin in [mmu_sensors].\n" + "The other settings (sync_feedback_analog_max_compression, sync_feedback_analog_max_tension " + "and sync_feedback_analog_neutral_point) will be determined by this calibration." + ) + + if not self.sensor_manager.has_sensor(self.SENSOR_PROPORTIONAL): + raise gcmd.error("Proportional (analog sync-feedback) sensor not found\n" + usage) + + def _avg_raw(n=10, dwell_s=0.1): + """ + Sample sensor.get_status(0)['value_raw'] n times with dwell between reads + and return moving average + """ + sensor = self.sensor_manager.all_sensors.get(self.SENSOR_PROPORTIONAL) + + k = 0.1 # 1st order,low pass filter coefficient, 0.1 for 10 samples + avg = sensor.get_status(0).get('value_raw', None) + + for _ in range(int(max(1, n-1))): + self.movequeues_dwell(dwell_s) + raw = sensor.get_status(0).get('value_raw', None) + if raw is None or not isinstance(raw, float): + return None + avg += k * (raw - avg) # 1st order low pass filter + return (avg) + + def _seek_limit(msg, steps, step_size, prev_val, ramp, log_label): + self.log_always(msg) + for i in range(steps): + _ = self.trace_filament_move(msg, step_size, motor="gear", speed=MOVE_SPEED, wait=True) + val = _avg_raw() + + delta = val - prev_val + + if ramp is None: + if delta == 0: + self.log_always("No sensor change. Retrying") + continue + ramp = (delta > 0) + + if (ramp and val >= prev_val) or (not ramp and val <= prev_val): + prev_val = val + self.log_always("Seeking ... ADC %s limit: %.4f" % (log_label, val)) + else: + # Limit found + return prev_val, ramp, True + + # Ran out of steps without detecting a clear limit + return prev_val, ramp, False + + try: + with self.wrap_sync_gear_to_extruder(): + with self.wrap_gear_current(percent=self.sync_gear_current, reason="while calibrating sync_feedback psensor"): + self.selector.filament_drive() + self.calibrating = True + + raw0 = _avg_raw() + if raw0 is None: + raise gcmd.error("Sensor malfunction. Could not read valid ADC output\nAre you sure you configured in [mmu_sensors]?") + + msg = "Finding compression limit stepping up to %.2fmm\n" % (steps * STEP_SIZE) + c_prev = raw0 + ramp = None + c_prev, ramp, found_c_limit = _seek_limit(msg, steps, STEP_SIZE, c_prev, ramp, "compressed") + + # Back off compressed extreme + msg = "Backing off compressed limit" + self.log_always(msg) + _ = self.trace_filament_move(msg, -(steps * STEP_SIZE / 2.0), motor="gear", speed=MOVE_SPEED, wait=True) + + msg = "Finding tension limit stepping up to %.2fmm\n" % (steps * STEP_SIZE) + t_prev = _avg_raw() + ramp = (not ramp) if found_c_limit else None # If compression succeeded, inverse ramp; otherwise re-detect + t_prev, ramp, found_t_limit = _seek_limit(msg, steps, -STEP_SIZE, t_prev, ramp, "tension") + + # Back off tension extreme + msg = "Backing off tension limit" + self.log_always(msg) + _ = self.trace_filament_move(msg, (steps * STEP_SIZE / 2.0), motor="gear", speed=MOVE_SPEED, wait=True) + + if (found_c_limit and found_t_limit): + msg = "Calibration Results:\n" + msg += "As wired, recommended settings (in mmu_hardware.cfg) are:\n" + msg += "[mmu_sensors]\n" + msg += "sync_feedback_analog_max_compression: %.4f\n" % c_prev + msg += "sync_feedback_analog_max_tension: %.4f\n" % t_prev + msg += "sync_feedback_analog_neutral_point: %.4f\n" % ((c_prev + t_prev) / 2.0) + msg += "After updating, don't forget to restart klipper!" + self.log_always(msg) + else: + msg = "Warning: calibration did not find both compression and tension " + msg += "limits (compression=%s, tension=%s)\n" % (found_c_limit, found_t_limit) + msg += "Perhaps sync_feedback_buffer_maxrange parameter is incorrect?\n" + msg += "Alternatively with bigger movement range by running with MOVE=" + self.log_warning(msg) + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + +####################### +# MMU STATE FUNCTIONS # +####################### + + def _setup_hotend_off_timer(self): + self.hotend_off_timer = self.reactor.register_timer(self._hotend_off_handler, self.reactor.NEVER) + + def _hotend_off_handler(self, eventtime): + if not self.is_printing(): + self.log_info("Disabled extruder heater") + self.gcode.run_script_from_command("M104 S0") + return self.reactor.NEVER + + def _setup_pending_spool_id_timer(self): + self.pending_spool_id_timer = self.reactor.register_timer(self._pending_spool_id_handler, self.reactor.NEVER) + + def _pending_spool_id_handler(self, eventtime): + self.pending_spool_id = -1 + return self.reactor.NEVER + + def _check_pending_spool_id(self, gate): + if self.pending_spool_id > 0 and self.spoolman_support != self.SPOOLMAN_PULL: + self.log_info("Spool ID: %s automatically assigned to gate %d" % (self.pending_spool_id, gate)) + mod_gate_ids = self.assign_spool_id(gate, self.pending_spool_id) + + # Request sync and update of filament attributes from Spoolman + if self.spoolman_support == self.SPOOLMAN_PUSH: + self._spoolman_push_gate_map(mod_gate_ids) + elif self.spoolman_support == self.SPOOLMAN_READONLY: + self._spoolman_update_filaments(mod_gate_ids) + + self._moonraker_push_lane_data(mod_gate_ids) + + # Disable timer to prevent reuse + self.pending_spool_id = -1 + self.reactor.update_timer(self.pending_spool_id_timer, self.reactor.NEVER) + + # Assign spool id to gate and clear from other gates returning list of changes + def assign_spool_id(self, gate, spool_id): + self.gate_spool_id[gate] = spool_id + mod_gate_ids = [(gate, spool_id)] + for i, sid in enumerate(self.gate_spool_id): + if sid == spool_id and i != gate: + self.gate_spool_id[i] = -1 + mod_gate_ids.append((i, -1)) + return mod_gate_ids + + def _handle_idle_timeout_printing(self, eventtime): + self._handle_idle_timeout_event(eventtime, "printing") + + def _handle_idle_timeout_ready(self, eventtime): + self._handle_idle_timeout_event(eventtime, "ready") + + def _handle_idle_timeout_idle(self, eventtime): + self._handle_idle_timeout_event(eventtime, "idle") + + def is_printing(self, force_in_print=False): # Actively printing and not paused + return self.print_state in ["started", "printing"] or force_in_print or self.test_force_in_print + + def is_in_print(self, force_in_print=False): # Printing or paused + return bool(self.print_state in ["printing", "pause_locked", "paused"] or force_in_print or self.test_force_in_print) + + def is_mmu_paused(self): # The MMU is paused + return self.print_state in ["pause_locked", "paused"] + + def is_mmu_paused_and_locked(self): # The MMU is paused (and locked) + return self.print_state in ["pause_locked"] + + def is_in_endstate(self): + return self.print_state in ["complete", "cancelled", "error", "ready", "standby", "initialized"] + + def is_in_standby(self): + return self.print_state in ["standby"] + + def is_printer_printing(self): + return bool(self.print_stats and self.print_stats.state == "printing") + + def is_printer_paused(self): + return self.pause_resume.is_paused + + def is_paused(self): + return self.is_printer_paused() or self.is_mmu_paused() + + def _wakeup(self): + if self.is_in_standby(): + self._set_print_state("idle") + + def _check_not_printing(self): + if self.is_printing(): + self.log_error("Operation not possible because currently printing") + return True + return False + + # Track print events simply to ease internal print state transitions. Specificly we want to detect + # the start and end of a print and falling back into 'standby' state on idle + # + # Klipper reference sources for state: + # print_stats: {'filename': '', 'total_duration': 0.0, 'print_duration': 0.0, + # 'filament_used': 0.0, 'state': standby|printing|paused|complete|cancelled|error, + # 'message': '', 'info': {'total_layer': None, 'current_layer': None}} + # idle_status: {'state': Idle|Ready|Printing, 'printing_time': 0.0} + # pause_resume: {'is_paused': True|False} + # + def _handle_idle_timeout_event(self, eventtime, event_type): + if not self.is_enabled: return + self.log_trace("Processing idle_timeout '%s' event" % event_type) + + if self.print_stats and self.print_start_detection: + new_ps = self.print_stats.get_status(eventtime) + if self.last_print_stats is None: + self.last_print_stats = dict(new_ps) + self.last_print_stats['state'] = 'initialized' + prev_ps = self.last_print_stats + old_state = prev_ps['state'] + new_state = new_ps['state'] + if new_state is not old_state: + if new_state == "printing" and event_type == "printing": + # Figure out the difference between initial job start and resume + if prev_ps['state'] == "paused" and prev_ps['filename'] == new_ps['filename'] and prev_ps['total_duration'] < new_ps['total_duration']: + # This is a 'resumed' state so ignore + self.log_trace("Automaticaly detected RESUME (ignored), print_stats=%s, current mmu print_state=%s" % (new_state, self.print_state)) + else: + # This is a 'started' state + self.log_trace("Automaticaly detected JOB START, print_status:print_stats=%s, current mmu print_state=%s" % (new_state, self.print_state)) + if self.print_state not in ["started", "printing"]: + self._on_print_start(pre_start_only=True) + self.reactor.register_callback(lambda pt: self._print_event("MMU_PRINT_START AUTOMATIC=1")) + elif new_state in ["complete", "error"] and event_type == "ready": + self.log_trace("Automatically detected JOB %s, print_stats=%s, current mmu print_state=%s" % (new_state.upper(), new_state, self.print_state)) + if new_state == "error": + self.reactor.register_callback(lambda pt: self._print_event("MMU_PRINT_END STATE=error AUTOMATIC=1")) + else: + self.reactor.register_callback(lambda pt: self._print_event("MMU_PRINT_END STATE=complete AUTOMATIC=1")) + self.last_print_stats = dict(new_ps) + + # Capture transition to standby + if event_type == "idle" and self.print_state != "standby": + self.reactor.register_callback(lambda pt: self._print_event("MMU_PRINT_END STATE=standby IDLE_TIMEOUT=1")) + + def _print_event(self, command): + try: + self.gcode.run_script(command) + except Exception: + logging.exception("MMU: Error running job state initializer/finalizer") + + # MMU job state machine: initialized|ready|started|printing|complete|cancelled|error|pause_locked|paused|standby + def _set_print_state(self, print_state, call_macro=True): + if print_state != self.print_state: + idle_timeout = self.printer.lookup_object("idle_timeout").idle_timeout + self.log_debug("Job State: %s -> %s (MMU State: Encoder: %s, Synced: %s, Paused temp: %s, Resume to state: %s, Position saved for: %s, pause_resume: %s, Idle timeout: %.2fs)" + % (self.print_state.upper(), print_state.upper(), self._get_encoder_state(), self.mmu_toolhead.is_gear_synced_to_extruder(), self.paused_extruder_temp, + self.resume_to_state, self.saved_toolhead_operation, self.is_printer_paused(), idle_timeout)) + if call_macro: + self.led_manager.print_state_changed(print_state, self.print_state) + if self.printer.lookup_object("gcode_macro %s" % self.print_state_changed_macro, None) is not None: + self.wrap_gcode_command("%s STATE='%s' OLD_STATE='%s'" % (self.print_state_changed_macro, print_state, self.print_state)) + self.print_state = print_state + + # If this is called automatically when printing starts. The pre_start_only operations are performed on an idle_timeout + # event so cannot block. The remainder of moves will be called from the queue but they will be called early so + # don't do anything that requires operating toolhead kinematics (we might not even be homed yet) + def _on_print_start(self, pre_start_only=False): + if self.print_state not in ["started", "printing"]: + self.log_trace("_on_print_start(->started)") + self._clear_saved_toolhead_position() + self.num_toolchanges = 0 + self.paused_extruder_temp = None + self._reset_job_statistics() # Reset job stats but leave persisted totals alone + self.reactor.update_timer(self.hotend_off_timer, self.reactor.NEVER) # Don't automatically turn off extruder heaters + self.is_handling_runout = False + self._clear_slicer_tool_map() + self._enable_filament_monitoring() # Enable filament monitoring while printing + self._initialize_encoder(dwell=None) # Encoder 0000 + self._set_print_state("started", call_macro=False) + + if not pre_start_only and self.print_state not in ["printing"]: + self.sync_feedback_manager.wipe_telemetry_logs() + self.log_trace("_on_print_start(->printing)") + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=min_lifted_z VALUE=0" % self.park_macro) # Sequential printing movement "floor" + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=next_pos VALUE=False" % self.park_macro) + msg = "Happy Hare initialized ready for print" + if self.filament_pos == self.FILAMENT_POS_LOADED: + msg += " (initial tool %s loaded)" % self.selected_tool_string() + else: + msg += " (no filament preloaded)" + if self.ttg_map != self.default_ttg_map: + msg += "\nWarning: Non default TTG map in effect" + self.log_info(msg) + self._set_print_state("printing") + + # Establish syncing state and grip (servo) position + # (must call after print_state is set so we know we are printing) + self.reset_sync_gear_to_extruder(self.sync_to_extruder) + + # Ensure espooler wasn't reset + self._adjust_espooler_assist() + + # Hack: Force state transistion to printing for any early moves if MMU_PRINT_START not yet run + def _fix_started_state(self): + if self.is_printer_printing() and not self.is_in_print(): + self.wrap_gcode_command("MMU_PRINT_START FIX_STATE=1") + + # If this is called automatically it will occur after the user's print ends. + # Therefore don't do anything that requires operating kinematics + def _on_print_end(self, state="complete"): + if not self.is_in_endstate(): + self.log_trace("_on_print_end(%s)" % state) + self.movequeues_wait() + self._clear_saved_toolhead_position() + self.resume_to_state = "ready" + self.paused_extruder_temp = None + self.reactor.update_timer(self.hotend_off_timer, self.reactor.NEVER) # Don't automatically turn off extruder heaters + self._restore_automap_option() + self._disable_filament_monitoring() # Disable filament monitoring + + if self.printer.lookup_object("idle_timeout").idle_timeout != self.default_idle_timeout: + self.gcode.run_script_from_command("SET_IDLE_TIMEOUT TIMEOUT=%d" % self.default_idle_timeout) # Restore original idle_timeout + + self._standalone_sync = False # Safer to clear this on print end or idle_timeout to standby to avoid user confusion + self._set_print_state(state) + + # Establish syncing state and grip (servo) position + # (must call after print_state is set) + self.reset_sync_gear_to_extruder(False) # Intention is not to sync unless we have to + + if state == "standby" and not self.is_in_standby(): + self._set_print_state(state) + self._clear_macro_state(reset=True) + + def handle_mmu_error(self, reason, force_in_print=False): + self._fix_started_state() # Get out of 'started' state before transistion to mmu pause + + run_pause_macro = run_error_macro = recover_pos = send_event = False + if self.is_in_print(force_in_print): + if not self.is_mmu_paused(): + self._disable_filament_monitoring() # Disable filament monitoring while in paused state + self._track_pause_start() + self.resume_to_state = 'printing' if self.is_in_print() else 'ready' + self.reason_for_pause = reason # Only store reason on first error + self._display_mmu_error() + self.paused_extruder_temp = self.printer.lookup_object(self.extruder_name).heater.target_temp + self.log_trace("Saved desired extruder temperature: %.1f%sC" % (self.paused_extruder_temp, UI_DEGREE)) + self.reactor.update_timer(self.hotend_off_timer, self.reactor.monotonic() + self.disable_heater) # Set extruder off timer + self.gcode.run_script_from_command("SET_IDLE_TIMEOUT TIMEOUT=%d" % self.timeout_pause) # Set alternative pause idle_timeout + self.log_trace("Extruder heater will be disabled in %s" % (self._seconds_to_string(self.disable_heater))) + self.log_trace("Idle timeout in %s" % self._seconds_to_string(self.timeout_pause)) + self._save_toolhead_position_and_park('pause') # if already paused this is a no-op + run_error_macro = True + run_pause_macro = not self.is_printer_paused() + send_event = True + recover_pos = self.filament_recovery_on_pause + self._set_print_state("pause_locked") + else: + self.log_error("MMU issue detected whilst printer is paused\nReason: %s" % reason) + recover_pos = self.filament_recovery_on_pause + + else: # Not in a print (standalone operation) + self.log_error("MMU issue: %s" % reason) + # Restore original position if parked because there will be no resume + if self.saved_toolhead_operation: + self._restore_toolhead_position(self.saved_toolhead_operation) + + # Be deliberate about order of these tasks + if run_error_macro: + self.wrap_gcode_command(self.error_macro) + + if run_pause_macro: + # Report errors and ensure we always pause + self.wrap_gcode_command(self.pause_macro, exception=False) + self.pause_resume.send_pause_command() + + if recover_pos: + self.recover_filament_pos(message=True) + + # Intention is not to sync unless we have to but will be restored on resume/continue_printing + self.reset_sync_gear_to_extruder(False) + + if send_event: + self.printer.send_event("mmu:mmu_paused") # Notify MMU paused event + + # Displays MMU error/pause as pop-up dialog and/or via console + def _display_mmu_error(self): + msg= "Print%s paused" % (" was already" if self.is_printer_paused() else " will be") + dialog_macro = self.printer.lookup_object('gcode_macro %s' % self.error_dialog_macro, None) + if self.show_error_dialog and dialog_macro is not None: + # Klipper doesn't handle string quoting so strip problematic characters + reason = self.reason_for_pause.replace("\n", ". ") + for c in "#;'": + reason = reason.replace(c, "") + self.wrap_gcode_command('%s MSG="%s" REASON="%s"' % (self.error_dialog_macro, msg, reason)) + self.log_error("MMU issue detected. %s\nReason: %s" % (msg, self.reason_for_pause)) + self.log_always("After fixing, call RESUME to continue printing (MMU_UNLOCK to restore temperature)") + + def _clear_mmu_error_dialog(self): + dialog_macro = self.printer.lookup_object('gcode_macro %s' % self.error_dialog_macro, None) + if self.show_error_dialog and dialog_macro is not None: + self.wrap_gcode_command('RESPOND TYPE=command MSG="action:prompt_end"') + + def _mmu_unlock(self): + if self.is_mmu_paused(): + self.gcode.run_script_from_command("SET_IDLE_TIMEOUT TIMEOUT=%d" % self.default_idle_timeout) + self.reactor.update_timer(self.hotend_off_timer, self.reactor.NEVER) + + # Important to wait for stable temperature to resume exactly how we paused + if self.paused_extruder_temp: + self.log_info("Enabled extruder heater") + self._ensure_safe_extruder_temperature("pause", wait=True) + self._set_print_state("paused") + + # Continue after load/unload/change_tool/runout operation or pause/error + def _continue_after(self, operation, force_in_print=False, restore=True): + self.log_debug("Continuing from %s state after %s" % (self.print_state, operation)) + if self.is_mmu_paused() and operation == 'resume': + self.reason_for_pause = None + self._ensure_safe_extruder_temperature("pause", wait=True) + self.paused_extruder_temp = None + self._track_pause_end() + if self.is_in_print(force_in_print): + self._enable_filament_monitoring() # Enable filament monitoring while printing + self._set_print_state(self.resume_to_state) + self.resume_to_state = "ready" + self.printer.send_event("mmu:mmu_resumed") + elif self.is_mmu_paused(): + # If paused we can only continue on resume + return + + if self.is_printing(force_in_print): + self.sensor_manager.confirm_loaded() # Can throw MmuError + self.is_handling_runout = False + self._initialize_encoder(dwell=None) # Encoder 0000 + + # Restablish desired syncing state and grip (servo) position + self.reset_sync_gear_to_extruder(self.sync_to_extruder, force_in_print=force_in_print) + + # Good place to reset the _next_tool marker because after any user fix on toolchange error/pause + self._next_tool = self.TOOL_GATE_UNKNOWN + + # Restore print position as final step so no delay + self._restore_toolhead_position(operation, restore=restore) + + # Ensure espooler wasn't reset + self._adjust_espooler_assist() + + # Ready to continue printing... + + def _clear_macro_state(self, reset=False): + if self.printer.lookup_object('gcode_macro %s' % self.clear_position_macro, None) is not None: + self.wrap_gcode_command("%s%s" % (self.clear_position_macro, " RESET=1" if reset else "")) + + def _save_toolhead_position_and_park(self, operation, next_pos=None): + if operation not in ['complete', 'cancel'] and 'xyz' not in self.toolhead.get_status(self.reactor.monotonic())['homed_axes']: + self.gcode.run_script_from_command(self.toolhead_homing_macro) + self.movequeues_wait() + + eventtime = self.reactor.monotonic() + homed = self.toolhead.get_status(eventtime)['homed_axes'] + if 'xyz' in homed: + if not self.saved_toolhead_operation: + # Save toolhead position + + # This is paranoia so I can be absolutely sure that Happy Hare leaves toolhead the same way when we are done + gcode_pos = self.gcode_move.get_status(eventtime)['gcode_position'] + toolhead_gcode_pos = " ".join(["%s:%.1f" % (a, v) for a, v in zip("XYZE", gcode_pos)]) + self.log_debug("Saving toolhead gcode state and position (%s) for %s" % (toolhead_gcode_pos, operation)) + self.gcode.run_script_from_command("SAVE_GCODE_STATE NAME=%s" % self.TOOLHEAD_POSITION_STATE) + self.saved_toolhead_operation = operation + + # Save toolhead velocity limits and set user defined for macros + self.saved_toolhead_max_accel = self.toolhead.max_accel + self.saved_toolhead_min_cruise_ratio = self.toolhead.get_status(eventtime).get('minimum_cruise_ratio', None) + cmd = "SET_VELOCITY_LIMIT ACCEL=%.4f" % self.macro_toolhead_max_accel + if self.saved_toolhead_min_cruise_ratio is not None: + cmd += " MINIMUM_CRUISE_RATIO=%.4f" % self.macro_toolhead_min_cruise_ratio + self.gcode.run_script_from_command(cmd) + + # Record the intended X,Y resume position (this is also passed to the pause/resume restore position in pause is later called) + if next_pos: + self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE]['last_position'][:2] = next_pos + + # Make sure we record the current speed/extruder overrides + if self.tool_selected >= 0: + mmu_state = self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE] + self.tool_speed_multipliers[self.tool_selected] = mmu_state['speed_factor'] * 60. + self.tool_extrusion_multipliers[self.tool_selected] = mmu_state['extrude_factor'] + + # This will save the print position in the macro and apply park + self.wrap_gcode_command(self.save_position_macro) + self.wrap_gcode_command(self.park_macro) + else: + # Re-apply parking for new operation (this will not change the saved position in macro) + + self.saved_toolhead_operation = operation # Update operation in progress + # Force re-park now because user may not be using HH client_macros. This can result + # in duplicate calls to parking macro but it is itempotent and will ignore + self.wrap_gcode_command(self.park_macro) + else: + self.log_debug("Cannot save toolhead position or z-hop for %s because not homed" % operation) + + def _restore_toolhead_position(self, operation, restore=True): + eventtime = self.reactor.monotonic() + if self.saved_toolhead_operation: + # Inject speed/extruder overrides into gcode state restore data + if self.tool_selected >= 0: + mmu_state = self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE] + mmu_state['speed_factor'] = self.tool_speed_multipliers[self.tool_selected] / 60. + mmu_state['extrude_factor'] = self.tool_extrusion_multipliers[self.tool_selected] + + # If this is the final "restore toolhead position" call then allow macro to restore position, then sanity check + # Note: if user calls BASE_RESUME, print will restart but from incorrect position that could be restored later! + if not self.is_paused() or operation == "resume": + # Controlled by the RESTORE=0 flag to MMU_LOAD, MMU_EJECT, MMU_CHANGE_TOOL (only real use case is final unload and perhaps initial load) + restore_macro = "%s RESTORE=%d" % (self.restore_position_macro, int(restore)) + # Restore macro position and clear saved + self.wrap_gcode_command(restore_macro) # Restore macro position and clear saved + + if restore: + # Paranoia: no matter what macros do ensure position and state is good. Either last, next or none (current x,y) + sequence_vars_macro = self.printer.lookup_object("gcode_macro _MMU_SEQUENCE_VARS", None) + travel_speed = 200 + if sequence_vars_macro: + if sequence_vars_macro.variables.get('restore_xy_pos', 'last') == 'none' and self.saved_toolhead_operation in ['toolchange']: + # Don't change x,y position on toolchange + current_pos = self.gcode_move.get_status(eventtime)['gcode_position'] + self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE]['last_position'][:2] = current_pos[:2] + travel_speed = sequence_vars_macro.variables.get('park_travel_speed', travel_speed) + gcode_pos = self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE]['last_position'] + display_gcode_pos = " ".join(["%s:%.1f" % (a, v) for a, v in zip("XYZE", gcode_pos)]) + self.gcode.run_script_from_command("RESTORE_GCODE_STATE NAME=%s MOVE=1 MOVE_SPEED=%.1f" % (self.TOOLHEAD_POSITION_STATE, travel_speed)) + self.log_debug("Ensuring correct gcode state and position (%s) after %s" % (display_gcode_pos, operation)) + + self._clear_saved_toolhead_position() + + # Always restore toolhead velocity limits + if self.saved_toolhead_max_accel: + cmd = "SET_VELOCITY_LIMIT ACCEL=%.4f" % self.saved_toolhead_max_accel + if self.saved_toolhead_min_cruise_ratio is not None: + cmd += " MINIMUM_CRUISE_RATIO=%.4f" % self.saved_toolhead_min_cruise_ratio + self.gcode.run_script_from_command(cmd) + self.saved_toolhead_max_accel = None + else: + pass # Resume will call here again shortly so we can ignore for now + else: + # Ensure all saved state is cleared + self._clear_macro_state() + self._clear_saved_toolhead_position() + + def _clear_saved_toolhead_position(self): + self.saved_toolhead_operation = '' + + def _disable_filament_monitoring(self): + eventtime = self.reactor.monotonic() + enabled = self.runout_enabled + self.runout_enabled = False + self.log_trace("Disabled FlowGuard and runout detection") + if self.has_encoder() and self.encoder_sensor.is_enabled(): + self.encoder_sensor.disable() + self.sensor_manager.disable_runout(self.gate_selected) + self.sync_feedback_manager.deactivate_flowguard(eventtime) + return enabled + + def _enable_filament_monitoring(self): + eventtime = self.reactor.monotonic() + self.runout_enabled = True + self.log_trace("Enabled FlowGuard and runout detection") + if self.has_encoder() and not self.encoder_sensor.is_enabled(): + self.encoder_sensor.enable() + self.sensor_manager.enable_runout(self.gate_selected) + self.sync_feedback_manager.activate_flowguard(eventtime) + self.runout_last_enable_time = eventtime + + @contextlib.contextmanager + def _wrap_suspend_filament_monitoring(self): + enabled = self._disable_filament_monitoring() + try: + yield self + finally: + if enabled: + self._enable_filament_monitoring() + + # To suppress visual filament position + @contextlib.contextmanager + def wrap_suppress_visual_log(self): + log_visual = self.log_visual + self.log_visual = 0 + try: + yield self + finally: + self.log_visual = log_visual + + def has_espooler(self): + return self.espooler is not None + + def _check_has_espooler(self): + if not self.has_espooler(): + self.log_error("No espooler fitted to this MMU unit") + return True + return False + + def has_encoder(self): + return self.encoder_sensor is not None and not self.test_disable_encoder + + def _can_use_encoder(self): + return self.has_encoder() and self.encoder_move_validation + + def _check_has_encoder(self): + if not self.has_encoder(): + self.log_error("No encoder fitted to this MMU unit") + return True + return False + + def _get_encoder_state(self): + if self.has_encoder(): + return "%s" % "Enabled" if self.encoder_sensor.is_enabled() else "Disabled" + else: + return "n/a" + + # For all encoder methods, 'dwell' means: + # True - gives klipper a little extra time to deliver all encoder pulses when absolute accuracy is required + # False - wait for moves to complete and then read encoder + # None - just read encoder without delay (assumes prior movements have completed) + # Return 'False' if no encoder fitted + def _encoder_dwell(self, dwell): + if self.has_encoder(): + if dwell: + self.movequeues_dwell(self.encoder_dwell) + self.movequeues_wait() + return True + elif dwell is False and self._can_use_encoder(): + self.movequeues_wait() + return True + elif dwell is None and self._can_use_encoder(): + return True + return False + + # Forces encoder to validate despite user desire (override 'encoder_move_validation' setting) + @contextlib.contextmanager + def _require_encoder(self): + if not self.has_encoder(): + raise MmuError("Assertion failure: Encoder required for chosen operation but not present on MMU") + validate = self.encoder_move_validation + self.encoder_move_validation = True + try: + yield self + finally: + self.encoder_move_validation = validate + + def get_encoder_distance(self, dwell=False): + if self._encoder_dwell(dwell): + return self.encoder_sensor.get_distance() + else: + return 0. + + def _get_encoder_counts(self, dwell=False): + if self._encoder_dwell(dwell): + return self.encoder_sensor.get_counts() + else: + return 0 + + def set_encoder_distance(self, distance, dwell=False): + if self._encoder_dwell(dwell): + self.encoder_sensor.set_distance(distance) + + def _initialize_encoder(self, dwell=False): + if self._encoder_dwell(dwell): + self.encoder_sensor.reset_counts() + + def _get_encoder_dead_space(self): + if self.has_encoder() and self.gate_homing_endstop in [self.SENSOR_GATE, self.SENSOR_GEAR_PREFIX]: + return self.gate_endstop_to_encoder + else: + return 0. + + def _initialize_filament_position(self, dwell=False): + self._initialize_encoder(dwell=dwell) + self._set_filament_position() + + def _get_filament_position(self): + return self.mmu_toolhead.get_position()[1] + + def _get_live_filament_position(self): + """ + Return the approximate live filament position + """ + gear_stepper = self.gear_rail.steppers[0] + mcu_pos = gear_stepper.get_mcu_position() + return mcu_pos * gear_stepper.get_step_dist() + + def _set_filament_position(self, position = 0.): + pos = self.mmu_toolhead.get_position() + pos[1] = position + self.mmu_toolhead.set_position(pos) + return position + + def _set_filament_remaining(self, length, color=''): + self.filament_remaining = length + self.save_variable(self.VARS_MMU_FILAMENT_REMAINING, max(0, round(length, 1))) + self.save_variable(self.VARS_MMU_FILAMENT_REMAINING_COLOR, color, write=True) + + def _set_last_tool(self, tool): + self._last_tool = tool + self.save_variable(self.VARS_MMU_LAST_TOOL, tool, write=True) + + def _set_filament_pos_state(self, state, silent=False): + if self.filament_pos != state: + self.filament_pos = state + if self.gate_selected != self.TOOL_GATE_BYPASS or state == self.FILAMENT_POS_UNLOADED or state == self.FILAMENT_POS_LOADED: + self._display_visual_state(silent=silent) + + # Minimal save_variable writes + if state in [self.FILAMENT_POS_LOADED, self.FILAMENT_POS_UNLOADED]: + self.save_variable(self.VARS_MMU_FILAMENT_POS, state, write=True) + elif self.save_variables.allVariables.get(self.VARS_MMU_FILAMENT_POS, 0) != self.FILAMENT_POS_UNKNOWN: + self.save_variable(self.VARS_MMU_FILAMENT_POS, self.FILAMENT_POS_UNKNOWN, write=True) + + self._adjust_espooler_assist() + + def _adjust_espooler_assist(self): + """ + Ensure espooler print assist is in correct state based on whether the filament is in the extruder or not + """ + if self.has_espooler(): + if self.filament_pos == self.FILAMENT_POS_LOADED: + if self.ESPOOLER_PRINT in self.espooler_operations and self.espooler_printing_power == 0: + # Enable in-print assist because filament is in the extruder + self.espooler.set_print_assist_mode(self.gate_selected) + else: + # Ensure in-print assist mode is removed + # (it could have been enabled manually with MMU_ESPOOLER) + self.espooler.reset_print_assist_mode() + + def _set_filament_direction(self, direction): + self.filament_direction = direction + + def _must_home_to_extruder(self): + return self.extruder_homing_endstop != self.SENSOR_EXTRUDER_NONE and (self.extruder_force_homing or not self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD)) + + def _must_buffer_extruder_homing(self): + return self._must_home_to_extruder() and self.extruder_homing_endstop != self.SENSOR_EXTRUDER_COLLISION + + def check_if_disabled(self): + if not self.is_enabled: + self.log_error("Operation not possible. MMU is disabled. Please use MMU ENABLE=1 to use") + return True + self._wakeup() + return False + + def check_if_printing(self): + if self.is_printing(): + self.log_error("Operation not possible. Printer is actively printing") + return True + return False + + def check_if_bypass(self, unloaded_ok=True): + if self.tool_selected == self.TOOL_GATE_BYPASS and (not unloaded_ok or self.filament_pos not in [self.FILAMENT_POS_UNLOADED]): + self.log_error("Operation not possible. MMU is currently using bypass. Unload or select a different gate first") + return True + return False + + def check_if_not_homed(self): + if not self.selector.is_homed: + self.log_error("Operation not possible. MMU selector is not homed") + return True + return False + + def check_if_loaded(self): + if self.filament_pos not in [self.FILAMENT_POS_UNLOADED, self.FILAMENT_POS_UNKNOWN]: + self.log_error("Operation not possible. Filament is loaded") + return True + return False + + def check_if_not_loaded(self): + if self.filament_pos != self.FILAMENT_POS_LOADED: + self.log_error("Operation not possible. Filament is not loaded") + return True + return False + + def check_if_gate_not_valid(self): + if self.gate_selected < 0: + self.log_error("Operation not possible. No MMU gate selected") + return True + return False + + def check_if_always_gripped(self): + if self.mmu_machine.filament_always_gripped: + self.log_error("Operation not possible. MMU design doesn't allow for manual override of syncing state.\nSyncing will be enabled if filament is inside the extruder.\nUse `MMU_RECOVER` to correct filament position if necessary.") + return True + return False + + def check_if_no_bowden_move(self): + if not self.mmu_machine.require_bowden_move: + self.log_error("Operation not possible. MMU design does not require bowden move/calibration") + return True + return False + + # Check if everything calibrated + # Returns True is required calibration is not complete. Defaults to all gates or a specific gate can + # be specified. The purpose of this is to highlight to the user what is not fully calibrated on their + # MMU. It will default to not reporting calibration steps that are optional based on "autotune" options + # Params: required - bitmap of required calibration checks + # silent - report errors (None = report but don't log as error) + # check_gates - list of gates to consider (None = all) + # use_autotune - True = don't warn if handled by autocal/autotune options, False = warn + def check_if_not_calibrated(self, required, silent=False, check_gates=None, use_autotune=True): + if not self.calibration_status & required == required: + if check_gates is None: + check_gates = list(range(self.num_gates)) + + rmsg = omsg = "" + if ( + (not use_autotune or not self.autocal_selector) and + (required & self.CALIBRATED_SELECTOR) and + not (self.calibration_status & self.CALIBRATED_SELECTOR) + ): + uncalibrated = self.selector.get_uncalibrated_gates(check_gates) + if uncalibrated: + info = "\n- Use MMU_CALIBRATE_SELECTOR to calibrate selector for gates: %s" % ",".join(map(str, uncalibrated)) + if self.autocal_selector: + omsg += info + else: + rmsg += info + + if ( + (not use_autotune or not self.skip_cal_rotation_distance) and + (required & self.CALIBRATED_GEAR_0) and + not (self.calibration_status & self.CALIBRATED_GEAR_0) + ): + uncalibrated = self.rotation_distances[0] == -1 + if uncalibrated: + info = "\n- Use MMU_CALIBRATE_GEAR (with gate 0 selected)" + info += " to calibrate gear rotation_distance on gate: 0" + if self.skip_cal_rotation_distance: + omsg += info + else: + rmsg += info + + if ( + (not use_autotune or not self.skip_cal_encoder) and + (required & self.CALIBRATED_ENCODER and + not (self.calibration_status & self.CALIBRATED_ENCODER)) + ): + info = "\n- Use MMU_CALIBRATE_ENCODER (with gate 0 selected)" + if self.skip_cal_encoder: + omsg += info + else: + rmsg += info + + if ( + self.mmu_machine.variable_rotation_distances and + (not use_autotune or not (self.skip_cal_rotation_distance or self.autotune_rotation_distance)) and + (required & self.CALIBRATED_GEAR_RDS) and + not (self.calibration_status & self.CALIBRATED_GEAR_RDS) + ): + uncalibrated = [gate for gate, value in enumerate(self.rotation_distances) if gate != 0 and value == -1 and gate in check_gates] + if uncalibrated: + if self.has_encoder(): + info = "\n- Use MMU_CALIBRATE_GEAR (with appropriate gate selected) or MMU_CALIBRATE_GATES GATE=xx" + info += " to calibrate gear rotation_distance on gates: %s" % ",".join(map(str, uncalibrated)) + else: + info = "\n- Use MMU_CALIBRATE_GEAR (with appropriate gate selected)" + info += " to calibrate gear rotation_distance on gates: %s" % ",".join(map(str, uncalibrated)) + if (self.skip_cal_rotation_distance or self.autotune_rotation_distance): + omsg += info + else: + rmsg += info + + if ( + (not use_autotune or not self.autocal_bowden_length) and + (required & self.CALIBRATED_BOWDENS) and + not (self.calibration_status & self.CALIBRATED_BOWDENS) + ): + if self.mmu_machine.variable_bowden_lengths: + uncalibrated = [gate for gate, value in enumerate(self.bowden_lengths) if value == -1 and gate in check_gates] + if uncalibrated: + info = "\n- Use MMU_CALIBRATE_BOWDEN (with gate selected)" + info += " to calibrate bowden length gates: %s" % ",".join(map(str, uncalibrated)) + if self.autocal_bowden_length: + omsg += info + else: + rmsg += info + else: + uncalibrated = self.bowden_lengths[0] == -1 + if uncalibrated: + info = "\n- Use MMU_CALIBRATE_BOWDEN (with gate 0 selected) to calibrate bowden length" + if self.autocal_bowden_length: + omsg += info + else: + rmsg += info + + if rmsg or omsg: + msg = "Warning: Calibration steps are not complete:" + if rmsg: + msg += "\nRequired:%s" % rmsg + if omsg: + msg += "\nOptional (handled by autocal/autotune):%s" % omsg + if not silent: + if silent is None: # Bootup/status use case to avoid looking like error + self.log_always("{2}%s{0}" % msg, color=True) + else: + self.log_error(msg) + return True + return False + + def check_if_spoolman_enabled(self): + if self.spoolman_support == self.SPOOLMAN_OFF: + self.log_error("Spoolman support is currently disabled") + return True + return False + + def _gate_homing_string(self): + return "ENCODER" if self.gate_homing_endstop == self.SENSOR_ENCODER else "%s sensor" % self.gate_homing_endstop + + def _ensure_safe_extruder_temperature(self, source="auto", wait=False): + extruder = self.printer.lookup_object(self.extruder_name) + current_temp = extruder.get_status(0)['temperature'] + current_target_temp = extruder.heater.target_temp + klipper_minimum_temp = extruder.get_heater().min_extrude_temp + gate_temp = self.gate_temperature[self.gate_selected] if self.gate_selected >= 0 and self.gate_temperature[self.gate_selected] > 0 else self.default_extruder_temp + self.log_trace("_ensure_safe_extruder_temperature: current_temp=%s, paused_extruder_temp=%s, current_target_temp=%s, klipper_minimum_temp=%s, gate_temp=%s, default_extruder_temp=%s, source=%s" % (current_temp, self.paused_extruder_temp, current_target_temp, klipper_minimum_temp, gate_temp, self.default_extruder_temp, source)) + + if source == "pause": + new_target_temp = self.paused_extruder_temp if self.paused_extruder_temp is not None else current_temp # Pause temp should not be None + if self.paused_extruder_temp < klipper_minimum_temp: + # Don't wait if just messing with cold printer + wait = False + + elif source == "auto": # Normal case + if self.is_mmu_paused(): + # In a pause we always want to restore the temp we paused at + if self.paused_extruder_temp is not None: + new_target_temp = self.paused_extruder_temp + source = "pause" + else: # Pause temp should not be None + new_target_temp = current_temp + source = "current" + + elif self.is_printing(): + if current_target_temp < klipper_minimum_temp: + # Almost certainly means the initial tool change before slicer has set + if self.gate_selected >= 0: + new_target_temp = gate_temp + source = "gatemap" + else: + new_target_temp = self.default_extruder_temp + source = "mmu default" + else: + # While actively printing, we want to defer to the slicer for temperature + new_target_temp = current_target_temp + source = "slicer" + + else: + # Standalone "just messing" case + if current_target_temp > klipper_minimum_temp: + new_target_temp = current_target_temp + source = "current" + else: + if self.gate_selected >= 0: + new_target_temp = gate_temp + source = "gatemap" + else: + new_target_temp = self.default_extruder_temp + source = "mmu default" + + # Final safety check + if new_target_temp <= klipper_minimum_temp: + new_target_temp = self.default_extruder_temp + source = "mmu default" + + if new_target_temp > current_target_temp: + if source in ["mmu default", "gatemap"]: + # We use error log channel to avoid heating surprise. This will also cause popup in Klipperscreen + self.log_error("Alert: Automatically heating extruder to %s temp (%.1f%sC)" % (source, new_target_temp, UI_DEGREE)) + else: + self.log_info("Heating extruder to %s temp (%.1f%sC)" % (source, new_target_temp, UI_DEGREE)) + wait = True # Always wait to warm up + + if new_target_temp > 0: + self.gcode.run_script_from_command("M104 S%.1f" % new_target_temp) + + # Optionally wait until temperature is stable or at minimum safe temp so extruder can move + if wait and new_target_temp >= klipper_minimum_temp and abs(new_target_temp - current_temp) > self.extruder_temp_variance: + with self.wrap_action(self.ACTION_HEATING): + self.log_info("Waiting for extruder to reach target (%s) temperature: %.1f%sC" % (source, new_target_temp, UI_DEGREE)) + self.gcode.run_script_from_command("TEMPERATURE_WAIT SENSOR=%s MINIMUM=%.1f MAXIMUM=%.1f" % (self.extruder_name, new_target_temp - self.extruder_temp_variance, new_target_temp + self.extruder_temp_variance)) + + def selected_tool_string(self, tool=None): + if tool is None: + tool = self.tool_selected + if tool == self.TOOL_GATE_BYPASS: + return "Bypass" + elif tool == self.TOOL_GATE_UNKNOWN: + return "Unknown" + else: + return "T%d" % tool + + def selected_gate_string(self, gate=None): + if gate is None: + gate = self.gate_selected + if gate == self.TOOL_GATE_BYPASS: + return "bypass" + elif gate == self.TOOL_GATE_UNKNOWN: + return "unknown" + else: + return "#%d" % gate + + def selected_unit_string(self, unit=None): + return " (unit #%d)" % self.unit_selected if self.mmu_machine.num_units > 1 else "" + + def _set_action(self, action): + if action == self.action: return action + old_action = self.action + self.action = action + self.led_manager.action_changed(action, old_action) + if self.printer.lookup_object("gcode_macro %s" % self.action_changed_macro, None) is not None: + self.wrap_gcode_command("%s ACTION='%s' OLD_ACTION='%s'" % (self.action_changed_macro, self._get_action_string(), self._get_action_string(old_action))) + return old_action + + @contextlib.contextmanager + def wrap_action(self, new_action): + old_action = self._set_action(new_action) + try: + yield (old_action, new_action) + finally: + self._set_action(old_action) + + def _enable_mmu(self): + if self.is_enabled: return + self.reinit() + self._load_persisted_state() + self.is_enabled = True + self.printer.send_event("mmu:enabled") + self.log_always("MMU enabled") + self._schedule_mmu_bootup_tasks() + + def _disable_mmu(self): + if not self.is_enabled: return + self.reinit() + self._disable_filament_monitoring() + self.reactor.update_timer(self.hotend_off_timer, self.reactor.NEVER) + self.gcode.run_script_from_command("SET_IDLE_TIMEOUT TIMEOUT=%d" % self.default_idle_timeout) + self.motors_onoff(on=False) # Will also unsync gear + self.is_enabled = False + self.printer.send_event("mmu:disabled") + self._set_print_state("standby") + self.log_always("MMU disabled") + + # Wrapper so we can minimize actual disk writes and batch updates + def save_variable(self, variable, value, write=False): + self.save_variables.allVariables[variable] = value + if write: + self.write_variables() + + def delete_variable(self, variable, write=False): + _ = self.save_variables.allVariables.pop(variable, None) + if write: + self.write_variables() + + def write_variables(self): + if self._can_write_variables: + mmu_vars_revision = self.save_variables.allVariables.get(self.VARS_MMU_REVISION, 0) + 1 + self.gcode.run_script_from_command("SAVE_VARIABLE VARIABLE=%s VALUE=%d" % (self.VARS_MMU_REVISION, mmu_vars_revision)) + + @contextlib.contextmanager + def _wrap_suspendwrite_variables(self): + self._can_write_variables = False + try: + yield self + finally: + self._can_write_variables = True + self.write_variables() + + def _random_failure(self): + if self.test_random_failures and random.randint(0, 10) == 0: + raise MmuError("Randomized testing failure") + + +### STATE GCODE COMMANDS ######################################################### + + cmd_MMU_help = "Enable/Disable functionality and reset state" + def cmd_MMU(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + enable = gcmd.get_int('ENABLE', minval=0, maxval=1) + if enable == 1: + self._enable_mmu() + else: + self._disable_mmu() + + cmd_MMU_HELP_help = "Display the complete set of MMU commands and function" + def cmd_MMU_HELP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + testing = gcmd.get_int('TESTING', 0, minval=0, maxval=1) + slicer = gcmd.get_int('SLICER', 0, minval=0, maxval=1) + callbacks = gcmd.get_int('CALLBACKS', 0, minval=0, maxval=1) + steps = gcmd.get_int('STEPS', 0, minval=0, maxval=1) + msg = "Happy Hare MMU commands: (use MMU_HELP SLICER=1 CALLBACKS=1 TESTING=1 STEPS=1 for full command set)\n" + tesing_msg = "\nCalibration and testing commands:\n" + slicer_msg = "\nPrint start/end or slicer macros (defined in mmu_software.cfg\n" + callback_msg = "\nCallbacks (defined in mmu_sequence.cfg, mmu_state.cfg)\n" + seq_msg = "\nAdvanced load/unload sequence and steps:\n" + cmds = list(self.gcode.ready_gcode_handlers.keys()) + cmds.sort() + + # Logic to partition commands: + for c in cmds: + d = self.gcode.gcode_help.get(c, "n/a") + + if (c.startswith("MMU_START") or c.startswith("MMU_END") or c in ["MMU_UPDATE_HEIGHT"]) and c not in ["MMU_ENDLESS_SPOOL"]: + slicer_msg += "%s : %s\n" % (c.upper(), d) # Print start/end macros + + elif c.startswith("MMU") and not c.startswith("MMU__"): + if any(substring in c for substring in ["_CALIBRATE", "_TEST", "_SOAKTEST", "MMU_COLD_PULL"]): + tesing_msg += "%s : %s\n" % (c.upper(), d) # Testing and calibration commands + else: + if c not in ["MMU_CHANGE_TOOL_STANDALONE", "MMU_CHECK_GATES", "MMU_REMAP_TTG", "MMU_FORM_TIP"]: # Remove aliases + msg += "%s : %s\n" % (c.upper(), d) # Base command + + elif c.startswith("_MMU"): + if c.startswith("_MMU_STEP") or c in ["_MMU_M400", "_MMU_LOAD_SEQUENCE", "_MMU_UNLOAD_SEQUENCE"]: + seq_msg += "%s : %s\n" % (c.upper(), d) # Invidual sequence step commands + elif c.startswith("_MMU_PRE_") or c.startswith("_MMU_POST_") or c in ["_MMU_ACTION_CHANGED", "_MMU_EVENT", "_MMU_PRINT_STATE_CHANGED"]: + callback_msg += "%s : %s\n" % (c.upper(), d) # Callbacks + + msg += slicer_msg if slicer else "" + msg += callback_msg if callbacks else "" + msg += tesing_msg if testing else "" + msg += seq_msg if steps else "" + self.log_always(msg) + + + cmd_MMU_ENCODER_help = "Display encoder position and stats or enable/disable runout detection logic in encoder" + def cmd_MMU_ENCODER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self._check_has_encoder(): return + if self.check_if_disabled(): return + value = gcmd.get_float('VALUE', -1, minval=0.) + enable = gcmd.get_int('ENABLE', -1, minval=0, maxval=1) + if enable == 1: + self.sync_feedback_manager.set_encoder_mode() + elif enable == 0: + self.sync_feedback_manager.set_encoder_mode(self.encoder_sensor.RUNOUT_DISABLED) + elif value >= 0.: + self.set_encoder_distance(value) + return + self.log_info(self._get_encoder_summary(detail=True)) + + + cmd_MMU_ESPOOLER_help = "Direct control of espooler or display of current status" + cmd_MMU_ESPOOLER_param_help = ( + "MMU_ESPOOLER: %s\n" % cmd_MMU_ESPOOLER_help + + "ALLOFF = [0|1] Quick way to turn all espoolers off\n" + + "TRIGGER = [0|1] Fire in-print trigger for testing\n" + + "BURST = [0|1] Jog in direction of OPERATION (assist|rewind) using configured burst duration and power\n" + + "DURATION = [0-10] Override duration of PWM signal (seconds) for burst operations\n" + + "GATE = g Specify gate to operate on (defaults to current gate)\n" + + "LOOSEN = [0|1] Quick way to loosen filament on spool\n" + + "OPERATION = [assist|off|print|rewind] Set espooler operation mode\n" + + "POWER = [0-100] Override default % power to apply to espooler motor\n" + + "QUIET = [0|1] Used to suppress console/log output\n" + + "RESET = [0|1] Turn of in-print assist\n" + + "TIGHTEN = [0|1] Quick way to tighten filament on spool\n" + + "(no parameters for status report)" + ) + def cmd_MMU_ESPOOLER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.log_always(self.format_help(self.cmd_MMU_ESPOOLER_param_help), color=True) + return + + if self._check_has_espooler(): return + + operation = gcmd.get('OPERATION', None) + burst = gcmd.get_int('BURST', 0, minval=0, maxval=1) + tighten = gcmd.get_int('TIGHTEN', 0, minval=0, maxval=1) + loosen = gcmd.get_int('LOOSEN', 0, minval=0, maxval=1) + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + alloff = bool(gcmd.get_int('ALLOFF', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + trigger = bool(gcmd.get_int('TRIGGER', 0, minval=0, maxval=1)) + gate = gcmd.get_int('GATE', None, minval=0, maxval=self.num_gates - 1) + + if reset: + # Turn off in-print assist mode + self.espooler.reset_print_assist_mode() + + if trigger: + # Mimick in-print assist trigger + # No gate specified = similar to extruder movement + # With gate specified = similar to filament tension trigger + self.espooler.advance(gate) + + if alloff: + for gate in range(self.num_gates): + self.espooler.set_operation(gate, 0, self.ESPOOLER_OFF) + + elif tighten or loosen: + if gate is None: + gate = self.gate_selected + if gate < 0: + raise gcmd.error("Invalid gate") + + power = self.espooler_assist_burst_power if loosen else self.espooler_rewind_burst_power + duration = self.espooler_assist_burst_duration if loosen else self.espooler_rewind_burst_duration + operation = self.ESPOOLER_ASSIST if loosen else self.ESPOOLER_REWIND + self.printer.send_event("mmu:espooler_burst", gate, power / 100., duration, operation) + + elif operation is not None: + operation = operation.lower() + + if gate is None: + gate = self.gate_selected + if gate < 0: + raise gcmd.error("Invalid gate") + + # Determine power + if burst: + default_power = self.espooler_assist_burst_power if operation == self.ESPOOLER_ASSIST else self.espooler_rewind_burst_power + else: + default_power = self.espooler_printing_power if operation == self.ESPOOLER_PRINT else 50 + power = gcmd.get_int('POWER', default_power, minval=0, maxval=100) if operation != self.ESPOOLER_OFF else 0 + + if burst: + default_duration = self.espooler_assist_burst_duration if operation == self.ESPOOLER_ASSIST else self.espooler_rewind_burst_duration + duration = gcmd.get_float('DURATION', default_duration, above=0., maxval=10.) + + if operation in [self.ESPOOLER_ASSIST, self.ESPOOLER_REWIND]: + self.log_info("Espooler burst on gate %d for %.1fs at %d%% power in %s direction" % (gate, duration, power, operation)) + self.printer.send_event("mmu:espooler_burst", gate, power / 100., duration, operation) + else: + self.log_error("Must specify 'assist' or 'rewind' operation for burst") + + elif operation not in self.ESPOOLER_OPERATIONS: + raise gcmd.error("Invalid operation. Options are: %s" % ", ".join(self.ESPOOLER_OPERATIONS)) + + elif operation == self.ESPOOLER_PRINT: + if self.is_printing(): + self.log_warning("Cannot set in-print assist mode for non selected gate while printing") + else: + if gate != self.gate_selected: + self.log_warning("In-print assist mode set for non selected gate - for testing only") + self.espooler.set_operation(gate, power / 100, self.ESPOOLER_PRINT) + + elif operation != self.ESPOOLER_OFF: + self.espooler.set_operation(gate, power / 100, operation) + else: + self.espooler.set_operation(gate, 0, self.ESPOOLER_OFF) + + if not quiet: + msg = "" + for gate in range(self.num_gates): + if msg: + msg += "\n" + msg += "{}".format(gate).ljust(2, UI_SPACE) + ": " + if self.has_espooler(): + operation, value = self.espooler.get_operation(gate) + burst = "" + if operation == self.ESPOOLER_PRINT and value == 0: + burst = " [assist for %.1fs at %d%% power " % (self.espooler_assist_burst_duration, self.espooler_assist_burst_power) + if self.espooler_assist_burst_trigger: + burst += "on trigger, max %d bursts]" % self.espooler_assist_burst_trigger_max + else: + burst += "every %.1fmm of extruder movement]" % self.espooler_assist_extruder_move_length + msg += "{}".format(operation).ljust(7, UI_SPACE) + " (%d%%)%s" % (round(value * 100), burst) + else: + msg += "not fitted" + self.log_always(msg) + + + cmd_MMU_RESET_help = "Forget persisted state and re-initialize defaults" + def cmd_MMU_RESET(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + confirm = gcmd.get_int('CONFIRM', 0, minval=0, maxval=1) + if confirm != 1: + self.log_always("You must re-run and add 'CONFIRM=1' to reset all state back to default") + return + self.reinit() + self._reset_statistics() + self._reset_endless_spool() + self._reset_ttg_map() + self._reset_gate_map() + self.save_variable(self.VARS_MMU_GATE_SELECTED, self.gate_selected) + self.save_variable(self.VARS_MMU_TOOL_SELECTED, self.tool_selected) + self.save_variable(self.VARS_MMU_FILAMENT_POS, self.filament_pos) + self.write_variables() + self.log_always("MMU state reset") + self._schedule_mmu_bootup_tasks() + + +######################################################### +# STEP FILAMENT LOAD/UNLOAD MACROS FOR USER COMPOSITION # +######################################################### + + cmd_MMU_TEST_FORM_TIP_help = "Convenience macro for calling the standalone tip forming functionality (or cutter logic)" + def cmd_MMU_TEST_FORM_TIP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + show = bool(gcmd.get_int('SHOW', 0, minval=0, maxval=1)) + run = bool(gcmd.get_int('RUN', 1, minval=0, maxval=1)) + force_in_print = bool(gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1)) # Mimick in-print syncing and current + + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.form_tip_macro, None) + if gcode_macro is None: + raise gcmd.error("Filament tip forming macro '%s' not found" % self.form_tip_macro) + gcode_vars = self.printer.lookup_object("gcode_macro %s_VARS" % self.form_tip_macro, gcode_macro) + + if reset: + if self.form_tip_vars is not None: + gcode_vars.variables = dict(self.form_tip_vars) + self.form_tip_vars = None + self.log_always("Reset '%s' macro variables to defaults" % self.form_tip_macro) + show = True + + if show: + msg = "Variable settings for macro '%s':" % self.form_tip_macro + for k, v in gcode_vars.variables.items(): + msg += "\nvariable_%s: %s" % (k, v) + self.log_always(msg) + return + + # Save restore point on first call + if self.form_tip_vars is None: + self.form_tip_vars = dict(gcode_vars.variables) + + for param in gcmd.get_command_parameters(): + value = gcmd.get(param) + param = param.lower() + if param.startswith("variable_"): + self.log_always("Removing 'variable_' prefix from '%s' - not necessary" % param) + param = param[9:] + if param in gcode_vars.variables: + gcode_vars.variables[param] = self._fix_type(value) + elif param not in ["reset", "show", "run", "force_in_print"]: + self.log_error("Variable '%s' is not defined for '%s' macro" % (param, self.form_tip_macro)) + + # Run the macro in test mode (final_eject is set) + msg = "Running macro '%s' with the following variable settings:" % self.form_tip_macro + for k, v in gcode_vars.variables.items(): + msg += "\nvariable_%s: %s" % (k, v) + self.log_always(msg) + + try: + with self.wrap_sync_gear_to_extruder(): + if run: + self._ensure_safe_extruder_temperature(wait=True) + + # Ensure sync state and mimick in print if requested + self.reset_sync_gear_to_extruder(self.sync_form_tip, force_in_print=force_in_print) + + _,_,_ = self._do_form_tip(test=not self.is_in_print(force_in_print)) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + + cmd_MMU_TEST_PURGE_help = "Convenience macro for calling the standalone purging macro" + def cmd_MMU_TEST_PURGE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + last_tool = gcmd.get_int('LAST_TOOL', self._last_tool, minval=0, maxval=self.num_gates - 1) + next_tool = gcmd.get_int('NEXT_TOOL', self.tool_selected, minval=0, maxval=self.num_gates - 1) + if next_tool < 0: next_tool = 0 + + if not self.purge_macro: + self.log_warning("Purge not possible because `purge_macro` is not defined") + return + + try: + # Determine purge volume for test (mimick regular call to purge macro) + self.toolchange_purge_volume = self._calc_purge_volume(last_tool, next_tool) + + _last_tool, _next_tool = self._last_tool, self._next_tool + self._last_tool, self._next_tool = last_tool, next_tool # Valid only during this test + + msg = "Note that the suggested purge volume is based on the current MMU_SLICER_TOOL_MAP" + msg += "\nIf this is not set you might find it useful to run 'MMU_CALC_PURGE_VOLUMES MULTIPLIER=..'" + msg += "\nto create a purge volume map from current filament colors. You can also specify" + msg += "'LAST_TOOL=.. NEXT_TOOL=..' to this command to override currently loaded tool" + self.log_info(msg) + + self.log_info("Calling purge macro '%s'" % self.purge_macro) + with self.wrap_action(self.ACTION_PURGING): + self.purge_standalone() + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + finally: + self.toolchange_purge_volume = 0. + self._last_tool, self._next_tool = _last_tool, _next_tool # Restore real values + + + cmd_MMU_STEP_LOAD_GATE_help = "User composable loading step: Move filament from gate to start of bowden" + def cmd_MMU_STEP_LOAD_GATE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + try: + with self.wrap_sync_gear_to_extruder(): + self._load_gate() + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_LOAD_GATE: %s" % str(ee)) + + cmd_MMU_STEP_UNLOAD_GATE_help = "User composable unloading step: Move filament from start of bowden and park in the gate" + def cmd_MMU_STEP_UNLOAD_GATE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + full = gcmd.get_int('FULL', 0) + try: + with self.wrap_sync_gear_to_extruder(): + _,_ = self._unload_gate(homing_max=self.calibration_manager.get_bowden_length() if full else None) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_UNLOAD_GATE: %s" % str(ee)) + + cmd_MMU_STEP_LOAD_BOWDEN_help = "User composable loading step: Smart loading of bowden" + def cmd_MMU_STEP_LOAD_BOWDEN(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + length = gcmd.get_float('LENGTH', None, minval=0.) + start_pos = gcmd.get_float('START_POS', 0.) + try: + with self.wrap_sync_gear_to_extruder(): + _,_ = self._load_bowden(length, start_pos=start_pos) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_LOAD_BOWDEN: %s" % str(ee)) + + cmd_MMU_STEP_UNLOAD_BOWDEN_help = "User composable unloading step: Smart unloading of bowden" + def cmd_MMU_STEP_UNLOAD_BOWDEN(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + length = gcmd.get_float('LENGTH', self.calibration_manager.get_bowden_length()) + try: + with self.wrap_sync_gear_to_extruder(): + _ = self._unload_bowden(length) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_UNLOAD_BOWDEN: %s" % str(ee)) + + cmd_MMU_STEP_HOME_EXTRUDER_help = "User composable loading step: Home to extruder sensor or entrance through collision detection" + def cmd_MMU_STEP_HOME_EXTRUDER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + try: + with self.wrap_sync_gear_to_extruder(): + _,_ = self._home_to_extruder(self.extruder_homing_max) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_HOME_EXTRUDER: %s" % str(ee)) + + cmd_MMU_STEP_LOAD_TOOLHEAD_help = "User composable loading step: Toolhead loading" + def cmd_MMU_STEP_LOAD_TOOLHEAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + extruder_only = gcmd.get_int('EXTRUDER_ONLY', 0) + try: + with self.wrap_sync_gear_to_extruder(): + _ = self._load_extruder(extruder_only) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_LOAD_TOOLHEAD: %s" % str(ee)) + + cmd_MMU_STEP_UNLOAD_TOOLHEAD_help = "User composable unloading step: Toolhead unloading" + def cmd_MMU_STEP_UNLOAD_TOOLHEAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0)) + park_pos = gcmd.get_float('PARK_POS', -self._get_filament_position()) # +ve value + try: + with self.wrap_sync_gear_to_extruder(): + # Precautionary validation of filament position + park_pos = min(self.toolhead_extruder_to_nozzle, max(0, park_pos)) + self._set_filament_position(-park_pos) + self._unload_extruder(extruder_only = extruder_only) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_UNLOAD_TOOLHEAD: %s" % str(ee)) + + cmd_MMU_STEP_HOMING_MOVE_help = "User composable loading step: Generic homing move" + def cmd_MMU_STEP_HOMING_MOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + allow_bypass = bool(gcmd.get_int('ALLOW_BYPASS', 0, minval=0, maxval=1)) + try: + with self.wrap_sync_gear_to_extruder(): + self._homing_move_cmd(gcmd, "User defined step homing move", allow_bypass=allow_bypass) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_HOMING_MOVE: %s" % str(ee)) + + cmd_MMU_STEP_MOVE_help = "User composable loading step: Generic move" + def cmd_MMU_STEP_MOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + allow_bypass = bool(gcmd.get_int('ALLOW_BYPASS', 0, minval=0, maxval=1)) + try: + with self.wrap_sync_gear_to_extruder(): + self._move_cmd(gcmd, "User defined step move", allow_bypass=allow_bypass) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_MOVE: %s" % str(ee)) + + cmd_MMU_STEP_SET_FILAMENT_help = "User composable loading step: Set filament position state" + def cmd_MMU_STEP_SET_FILAMENT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + state = gcmd.get_int('STATE', minval=self.FILAMENT_POS_UNKNOWN, maxval=self.FILAMENT_POS_LOADED) + silent = gcmd.get_int('SILENT', 0) + self._set_filament_pos_state(state, silent) + + cmd_MMU_STEP_SET_ACTION_help = "User composable loading step: Set action state" + def cmd_MMU_STEP_SET_ACTION(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if gcmd.get_int('RESTORE', 0): + if self._old_action is not None: + self._set_action(self._old_action) + self._old_action = None + else: + state = gcmd.get_int('STATE', minval=self.ACTION_IDLE, maxval=self.ACTION_PURGING) + if self._old_action is None: + self._old_action = self._set_action(state) + else: + self._set_action(state) + + +############################################## +# MODULAR FILAMENT LOAD AND UNLOAD FUNCTIONS # +############################################## + + # Preload selected gate as little as possible. If a full gate load is the only option + # this will then park correctly after pre-load + def _preload_gate(self): + gate_sensor = self.sensor_manager.check_gate_sensor(self.SENSOR_GEAR_PREFIX, self.gate_selected) + if gate_sensor is not None: + if gate_sensor: + self.log_always("Filament already preloaded") + self._set_gate_status(self.gate_selected, self.GATE_AVAILABLE) + return + else: + # Minimal load to gear sensor if fitted + endstop_name = self.sensor_manager.get_gate_sensor_name(self.SENSOR_GEAR_PREFIX, self.gate_selected) + self.log_always("Preloading...") + msg = "Homing to %s sensor" % endstop_name + with self._wrap_suspend_filament_monitoring(): + actual,homed,measured,_ = self.trace_filament_move(msg, self.gate_preload_homing_max, motor="gear", homing_move=1, endstop_name=endstop_name) + if homed: + self.trace_filament_move("Final parking", -self.gate_preload_parking_distance) + self._set_gate_status(self.gate_selected, self.GATE_AVAILABLE) + self._check_pending_spool_id(self.gate_selected) # Have spool_id ready? + self.log_always("Filament detected and loaded in gate %d" % self.gate_selected) + return + else: + # Full gate load if no gear sensor + for _ in range(self.preload_attempts): + self.log_always("Loading...") + try: + self._load_gate(allow_retry=False) + self._check_pending_spool_id(self.gate_selected) # Have spool_id ready? + self.log_always("Parking...") + _,_ = self._unload_gate() + self.log_always("Filament detected and parked in gate %d" % self.gate_selected) + return + except MmuError as ee: + # Exception just means filament is not loaded yet, so continue + self.log_trace("Exception on preload: %s" % str(ee)) + + if self.sensor_manager.check_gate_sensor(self.SENSOR_PRE_GATE_PREFIX, self.gate_selected): + self._set_gate_status(self.gate_selected, self.GATE_UNKNOWN) + self.log_warning("Filament detected by pre-gate %d sensor but did not complete preload" % self.gate_selected) + else: + self._set_gate_status(self.gate_selected, self.GATE_EMPTY) + raise MmuError("Filament not detected") + + # Eject final clear of gate. Important for MMU's where filament is always gripped (e.g. most type-B) + def _eject_from_gate(self, gate=None): + # If gate not specified assume current gate + if gate is None: + gate = self.gate_selected + else: + self.select_gate(gate) + self.selector.filament_drive() + + self.log_always("Ejecting...") + if self.sensor_manager.has_gate_sensor(self.SENSOR_GEAR_PREFIX, gate): + endstop_name = self.sensor_manager.get_gate_sensor_name(self.SENSOR_GEAR_PREFIX, gate) + msg = "Reverse homing off %s sensor" % endstop_name + actual,homed,measured,_ = self.trace_filament_move(msg, -self.gate_homing_max, motor="gear", homing_move=-1, endstop_name=endstop_name) + if homed: + self.log_debug("Endstop %s reached after %.1fmm (measured %.1fmm)" % (endstop_name, actual, measured)) + else: + raise MmuError("Filament did not exit gate homing sensor: %s" % endstop_name) + + if self.gate_final_eject_distance > 0: + msg = "Ejecting filament out of gate" + if self.sensor_manager.check_gate_sensor(self.SENSOR_PRE_GATE_PREFIX, gate) is not None: + # Use homing move so we don't "over eject" + self.trace_filament_move(msg, -self.gate_final_eject_distance, motor="gear", homing_move=-1, endstop_name=self.SENSOR_PRE_GATE_PREFIX, wait=True) + else: + self.trace_filament_move(msg, -self.gate_final_eject_distance, wait=True) + + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=True) # Should already be in this position + self._set_gate_status(gate, self.GATE_EMPTY) + self.log_always("The filament in gate %d can be removed" % gate) + + # Load filament into gate. This is considered the starting position for the rest of the filament loading + # process. Note that this may overshoot the home position for the "encoder" technique but subsequent + # bowden move will accommodate. Also for systems with gate sensor and encoder with gate sensor first, + # there will be a gap in encoder readings that must be taken into consideration. + # Return the overshoot past homing point + def _load_gate(self, allow_retry=True): + self._validate_gate_config("load") + self._set_filament_direction(self.DIRECTION_LOAD) + self.selector.filament_drive() + retries = self.gate_load_retries if allow_retry else 1 + + if self.gate_homing_endstop == self.SENSOR_ENCODER: + with self._require_encoder(): + measured = 0. + for i in range(retries): + msg = "Initial load into encoder" if i == 0 else ("Retry load into encoder (reetry #%d)" % i) + _,_,m,_ = self.trace_filament_move(msg, self.gate_homing_max) + measured += m + if m > 6.0: + self._set_gate_status(self.gate_selected, max(self.gate_status[self.gate_selected], self.GATE_AVAILABLE)) # Don't reset if filament is buffered + self._set_filament_pos_state(self.FILAMENT_POS_START_BOWDEN) + return measured + else: + self.log_debug("Error loading filament - filament motion was not detected by the encoder. %s" % ("Retrying..." if i < retries - 1 else "")) + if i < retries - 1: + self.selector.filament_release() + self.selector.filament_drive() + + else: # Gate sensor... SENSOR_GATE is shared, but SENSOR_GEAR_PREFIX is specific + for i in range(retries): + endstop_name = self.sensor_manager.get_mapped_endstop_name(self.gate_homing_endstop) + msg = ("Initial homing to %s sensor" % endstop_name) if i == 0 else ("Retry homing to gate sensor (retry #%d)" % i) + h_dir = -1 if self.gate_parking_distance < 0 and self.sensor_manager.check_sensor(endstop_name) else 1 # Reverse home? + actual,homed,measured,_ = self.trace_filament_move(msg, h_dir * self.gate_homing_max, motor="gear", homing_move=h_dir, endstop_name=endstop_name) + if homed: + self.log_debug("Endstop %s reached after %.1fmm (measured %.1fmm)" % (endstop_name, actual, measured)) + self._set_gate_status(self.gate_selected, max(self.gate_status[self.gate_selected], self.GATE_AVAILABLE)) # Don't reset if filament is buffered + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_GATE) + return 0. + else: + self.log_debug("Error loading filament - filament did not reach gate homing sensor. %s" % ("Retrying..." if i < retries - 1 else "")) + if i < retries - 1: + self.selector.filament_release() + self.selector.filament_drive() + + self._set_gate_status(self.gate_selected, self.GATE_EMPTY) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + msg = "Couldn't pick up filament at gate" + if self.gate_homing_endstop == self.SENSOR_ENCODER: + msg += " (encoder didn't report enough movement)" + else: + msg += " (gate endstop didn't trigger)" + msg += "\nGate marked as empty. Use 'MMU_GATE_MAP GATE=%d AVAILABLE=1' to reset" % self.gate_selected + raise MmuError(msg) + + # Unload filament through gate to final MMU park position. + # Strategies include use of encoder or homing to gate/gear endstop and then parking + # Allows the overriding of homing_max for slow unloads when we are unsure of filament position + # Returns the amount of homing performed to aid calibration + def _unload_gate(self, homing_max=None): + self._validate_gate_config("unload") + self._set_filament_direction(self.DIRECTION_UNLOAD) + self.selector.filament_drive() + full = homing_max == self.calibration_manager.get_bowden_length() + homing_max = homing_max or self.gate_homing_max + + if full: # Means recovery operation + # Safety step because this method is used as a defensive way to unload the entire bowden from unknown position + # It handles the cases of filament still in extruder with no toolhead sensor or, if toolhead sensor is available, + # the small window where filament is between extruder entrance and toolhead sensor + homing_max += self.gate_homing_max # Full bowden may not be quite enough + length = self.toolhead_extruder_to_nozzle + if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + length -= self.toolhead_sensor_to_nozzle # Can safely reduce the base move distance because starting point in toolhead sensor + length += self.toolhead_unload_safety_margin # Add safety margin + + self.log_debug("Performing synced pre-unload bowden move of %.1fmm to ensure filament is not trapped in extruder" % length) + if self.gate_homing_endstop == self.SENSOR_ENCODER: + _,_,_,_ = self.trace_filament_move("Bowden safety pre-unload move", -length, motor="gear+extruder") + else: + endstop_name = self.sensor_manager.get_mapped_endstop_name(self.gate_homing_endstop) + actual,homed,_,_ = self.trace_filament_move("Bowden safety pre-unload move", -length, motor="gear+extruder", homing_move=-1, endstop_name=endstop_name) + # In case we ended up homing during the safety pre-unload, lets just do our parking and be done + # This can easily happen when your parking distance is configured to park the filament past the + # gate sensor instead of behind the gate sensor and the filament position is determined to be + # "somewhere in the bowden tube" + if homed: + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_GATE) + self.trace_filament_move("Final parking", -self.gate_parking_distance) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + return actual, self.gate_unload_buffer + + if self.gate_homing_endstop == self.SENSOR_ENCODER: + with self._require_encoder(): + if full: + self.log_info("Slowly unloading bowden because unsure of filament position...") + else: + self.log_trace("Unloading gate using the encoder") + success = self._reverse_home_to_encoder(homing_max) + if success: + actual,park,_ = success + _,_,measured,_ = self.trace_filament_move("Final parking", -park) + # We don't expect any movement of the encoder unless it is free-spinning + if measured > self.encoder_min: # We expect 0, but relax the test a little (allow one pulse) + self.log_warning("Warning: Possible encoder malfunction (free-spinning) during final filament parking") + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + return actual, self.gate_unload_buffer + msg = "did not clear the encoder after moving %.1fmm" % homing_max + + else: # Using mmu_gate or mmu_gear_N sensor + endstop_name = self.sensor_manager.get_mapped_endstop_name(self.gate_homing_endstop) + actual,homed,_,_ = self.trace_filament_move("Reverse homing off %s sensor" % endstop_name, -homing_max, motor="gear", homing_move=-1, endstop_name=endstop_name) + if homed: + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_GATE) + self.trace_filament_move("Final parking", -self.gate_parking_distance) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + return actual, self.gate_unload_buffer + msg = "did not home to sensor '%s' after moving %1.fmm" % (self.gate_homing_endstop, homing_max) + + raise MmuError("Failed to unload gate because %s" % msg) + + # Shared with manual bowden calibration routine + def _reverse_home_to_encoder(self, homing_max): + max_steps = int(math.ceil(homing_max / self.encoder_move_step_size)) + delta = 0. + actual = 0. + for i in range(max_steps): + msg = "Unloading step #%d from encoder" % (i+1) + sactual,_,_,sdelta = self.trace_filament_move(msg, -self.encoder_move_step_size) + delta += sdelta + actual -= sactual + # Large enough delta here means we are out of the encoder + if sdelta >= self.encoder_move_step_size * 0.2: # 20 % + actual -= sdelta + park = self.gate_parking_distance - sdelta # will be between 8 and 20mm (for 23mm gate_parking_distance, 15mm step) + return actual, park, delta + self.log_debug("Filament did not clear encoder even after moving %.1fmm" % (self.encoder_move_step_size * max_steps)) + return None + + # Shared gate functions to deduplicate logic + def _validate_gate_config(self, direction): + if self.gate_homing_endstop == self.SENSOR_ENCODER: + if not self.has_encoder(): + raise MmuError("Attempting to %s encoder but encoder is not configured on MMU!" % direction) + elif self.gate_homing_endstop in self.GATE_ENDSTOPS: + sensor = self.gate_homing_endstop + if self.gate_homing_endstop == self.SENSOR_GEAR_PREFIX: + sensor += "_%d" % self.gate_selected + if not self.sensor_manager.has_sensor(sensor): + raise MmuError("Attempting to %s gate but sensor '%s' is not configured on MMU!" % (direction, sensor)) + else: + raise MmuError("Unsupported gate endstop %s" % self.gate_homing_endstop) + + # Fast load of filament in bowden, usually the full length but if 'full' is False a specific length can be specified + # Note that filament position will be measured from the gate "parking position" and so will be the gate_parking_distance + # plus any overshoot. The start of the bowden move is from the parking homing point. + # Returns ratio of measured movement to real movement IF it is "clean" and could be used for auto-calibration else 0 + def _load_bowden(self, length=None, start_pos=0.): + bowden_length = self.calibration_manager.get_bowden_length() + if length is None: + length = bowden_length + if bowden_length > 0 and not self.calibrating: + length = min(length, bowden_length) # Cannot exceed calibrated distance + full = length == bowden_length + + # Compensate for distance already moved for gate homing endstop (e.g. overshoot after encoder based gate homing) + length -= start_pos + + try: + # Do we need to reduce by buffer amount to ensure we don't overshoot homing sensor + deficit = 0. + if full: + if self._must_buffer_extruder_homing(): + deficit = self.extruder_homing_buffer + # Further reduce to compensate for distance from extruder sensor to extruder entry gear + deficit -= self.toolhead_entry_to_extruder if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_ENTRY else 0 + length -= deficit # Reduce fast move distance + + if length > 0: + self.log_debug("Loading bowden tube") + self._set_filament_direction(self.DIRECTION_LOAD) + self.selector.filament_drive() + self._set_filament_pos_state(self.FILAMENT_POS_START_BOWDEN) + + # Record starting position for bowden progress tracking. Prefer encoder if available + self.bowden_start_pos = (self.get_encoder_distance(dwell=None) if self.has_encoder() else self._get_live_filament_position()) - start_pos + + if self.gate_selected > 0 and self.rotation_distances[self.gate_selected] <= 0: + self.log_warning("Warning: gate %d not calibrated! Using default rotation distance" % self.gate_selected) + + # "Fast" load + _,_,_,delta = self.trace_filament_move("Fast loading move through bowden", length, track=True, encoder_dwell=bool(self.autotune_rotation_distance)) + delta -= self._get_encoder_dead_space() + ratio = (length - delta) / length + + # Encoder based validation test + if self._can_use_encoder() and delta >= length * (self.bowden_move_error_tolerance / 100.) and not self.calibrating: + raise MmuError("Failed to load bowden. Perhaps filament is stuck in gate. Gear moved %.1fmm, Encoder measured %.1fmm" % (length, length - delta)) + + # Encoder based validation test + if self._can_use_encoder() and delta >= self.bowden_allowable_load_delta and not self.calibrating: + ratio = 0. # Not considered valid for auto-calibration + # Correction attempts to load the filament according to encoder reporting + if self.bowden_apply_correction: + for i in range(2): + if delta >= self.bowden_allowable_load_delta: + msg = "Correction load move #%d into bowden" % (i+1) + _,_,_,d = self.trace_filament_move(msg, delta, track=True) + delta = d + self.log_debug("Correction load move was necessary, encoder now measures %.1fmm" % self.get_encoder_distance()) + else: + self.log_debug("Correction load complete, delta %.1fmm is less than 'bowden_allowable_unload_delta' (%.1fmm)" % (delta, self.bowden_allowable_load_delta)) + break + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + if delta >= self.bowden_allowable_load_delta: + self.log_warning("Warning: Excess slippage was detected in bowden tube load afer correction moves. Gear moved %.1fmm, Encoder measured %.1fmm. See mmu.log for more details"% (length, length - delta)) + else: + self.log_warning("Warning: Excess slippage was detected in bowden tube load but 'bowden_apply_correction' is disabled. Gear moved %.1fmm, Encoder measured %.1fmm. See mmu.log for more details" % (length, length - delta)) + + if delta >= self.bowden_allowable_load_delta: + self.log_debug("Possible causes of slippage:\nCalibration ref length too long (hitting extruder gear before homing)\nCalibration ratio for gate is not accurate\nMMU gears are not properly gripping filament\nEncoder reading is inaccurate\nFaulty servo") + + self._random_failure() # Testing + self.movequeues_wait() + else: + # No bowden movement required + ratio = 1. + + if full: + self._set_filament_pos_state(self.FILAMENT_POS_END_BOWDEN) + elif self.filament_pos != self.FILAMENT_POS_IN_BOWDEN: + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + ratio = 0. + return ratio, deficit # For auto-calibration + finally: + self.bowden_start_pos = None + + # Fast unload of filament from exit of extruder gear (end of bowden) to position close to MMU (gate_unload_buffer away) + def _unload_bowden(self, length=None): + bowden_length = self.calibration_manager.get_bowden_length() + if length is None: + length = bowden_length + if bowden_length > 0 and not self.calibrating: + length = min(length, bowden_length) # Cannot exceed calibrated distance + full = length == bowden_length + + # Shorten move by gate buffer used to ensure we don't overshoot homing point + length -= self.gate_unload_buffer + + try: + if length > 0: + self.log_debug("Unloading bowden tube") + self._set_filament_direction(self.DIRECTION_UNLOAD) + self.selector.filament_drive() + + # Optional pre-unload safety step + if (full and self.has_encoder() and self.bowden_pre_unload_test and + self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY) is not False and + self.sensor_manager.check_all_sensors_before(self.FILAMENT_POS_START_BOWDEN, self.gate_selected, loading=False) is not False + ): + with self._require_encoder(): + self.log_debug("Performing bowden pre-unload test") + _,_,_,delta = self.trace_filament_move("Bowden pre-unload test", -self.encoder_move_step_size) + if delta > self.encoder_move_step_size * (self.bowden_pre_unload_error_tolerance / 100.): + self._set_filament_pos_state(self.FILAMENT_POS_EXTRUDER_ENTRY) + raise MmuError("Bowden pre-unload test failed. Filament seems to be stuck in the extruder or filament not loaded\nOptionally use MMU_RECOVER to recover filament position") + length -= self.encoder_move_step_size + + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + + # Record starting position for bowden progress tracking. Prefer encoder if available + self.bowden_start_pos = self.get_encoder_distance(dwell=None) if self.has_encoder() else self._get_live_filament_position() + + # Sensor validation + if self.sensor_manager.check_all_sensors_before(self.FILAMENT_POS_START_BOWDEN, self.gate_selected, loading=False) is False: + sensors = self.sensor_manager.get_all_sensors() + sensor_msg = '' + sname = [] + for name, state in sensors.items(): + sensor_msg += "%s (%s), " % (name.upper(), "Disabled" if state is None else ("Detected" if state is True else "Empty")) + if state is False: + sname.append(name) + self.log_warning("Warning: Possible sensor malfunction - %s sensor indicated no filament present prior to unloading bowden\nWill ignore and attempt to continue..." % ", ".join(sname)) + self.log_debug("Sensor state: %s" % sensor_msg) + + # "Fast" unload + _,_,_,delta = self.trace_filament_move("Fast unloading move through bowden", -length, track=True, encoder_dwell=bool(self.autotune_rotation_distance)) + delta -= self._get_encoder_dead_space() + ratio = (length - delta) / length + + # Encoder based validation test + if self._can_use_encoder() and delta >= self.bowden_allowable_unload_delta and not self.calibrating: + ratio = 0. + # Only a warning because _unload_gate() will deal with it + self.log_warning("Warning: Excess slippage was detected in bowden tube unload. Gear moved %.1fmm, Encoder measured %.1fmm" % (length, length - delta)) + + self._random_failure() # Testing + self.movequeues_wait() + else: + # No bowden movement required + ratio = 1. + + if full: + self._set_filament_pos_state(self.FILAMENT_POS_START_BOWDEN) + elif self.filament_pos != self.FILAMENT_POS_IN_BOWDEN: + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + ratio = 0. + return ratio # For auto-calibration + + finally: + self.bowden_start_pos = None + + # Optionally home filament to designated homing location at the extruder + # Returns any homing distance and extra movement for automatic calibration logic + # or None if not applicable + def _home_to_extruder(self, max_length): + self._set_filament_direction(self.DIRECTION_LOAD) + self.selector.filament_drive() + measured = extra = 0. + homing_movement = None + + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_NONE: + homed = True + + elif self.extruder_homing_endstop == self.SENSOR_EXTRUDER_COLLISION: + if self.has_encoder(): + actual,homed,measured,_ = self._home_to_extruder_collision_detection(max_length) + homing_movement = actual + else: + raise MmuError("Cannot home to extruder using 'collision' method because encoder is not configured or disabled!") + + else: + self.log_debug("Homing to extruder '%s' endstop, up to %.1fmm" % (self.extruder_homing_endstop, max_length)) + actual,homed,measured,_ = self.trace_filament_move("Homing filament to extruder endstop", max_length, motor="gear", homing_move=1, endstop_name=self.extruder_homing_endstop) + if homed: + self.log_debug("Extruder endstop '%s' reached after %.1fmm (measured %.1fmm)" % (self.extruder_homing_endstop, actual, measured)) + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_ENTRY) + + # Make adjustment based on sensor: extruder - move a little move, compression - back off a little + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_ENTRY: + extra = self.toolhead_entry_to_extruder + _,_,measured,_ = self.trace_filament_move("Aligning filament to extruder gear", extra, motor="gear") + elif self.extruder_homing_endstop == self.SENSOR_COMPRESSION: + # We don't actually back off because the buffer absorbs the overrun but we still report for calibration + extra = -(self.sync_feedback_manager.sync_feedback_buffer_range / 2.) + + homing_movement = actual + + if not homed: + self._set_filament_pos_state(self.FILAMENT_POS_END_BOWDEN) + raise MmuError("Failed to reach extruder '%s' endstop after moving %.1fmm" % (self.extruder_homing_endstop, max_length)) + + if measured > (max_length * 0.8): + self.log_warning("Warning: 80%% of 'extruder_homing_max' was used homing. You may want to increase 'extruder_homing_max'") + + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_EXTRUDER) + return homing_movement, extra + + # Special extruder homing option for detecting the collision base on lack of encoder movement + def _home_to_extruder_collision_detection(self, max_length): + # Lock the extruder stepper + stepper_enable = self.printer.lookup_object('stepper_enable') + ge = stepper_enable.lookup_enable(self.mmu_extruder_stepper.stepper.get_name()) + ge.motor_enable(self.toolhead.get_last_move_time()) + + step = self.extruder_collision_homing_step * math.ceil(self.encoder_resolution * 10) / 10 + self.log_debug("Homing to extruder gear, up to %.1fmm in %.1fmm steps" % (max_length, step)) + + with self.wrap_gear_current(self.extruder_collision_homing_current, "for collision detection"): + homed = False + measured = delta = 0. + i = 0 + for i in range(int(max_length / step)): + msg = "Homing step #%d" % (i+1) + _,_,smeasured,sdelta = self.trace_filament_move(msg, step, speed=self.gear_homing_speed) + measured += smeasured + delta += sdelta + if sdelta >= self.encoder_min or abs(delta) > step: # Not enough or strange measured movement means we've hit the extruder + homed = True + measured -= step # Subtract the last step to improve accuracy + break + self.log_debug("Extruder entrance%s found after %.1fmm move (%d steps), encoder measured %.1fmm (delta %.1fmm)" + % (" not" if not homed else "", step*(i+1), i+1, measured, delta)) + + if delta > 5.0: + self.log_warning("Warning: A lot of slippage was detected whilst homing to extruder, you may want to reduce 'extruder_collision_homing_current' and/or ensure a good grip on filament by gear drive") + + self._set_filament_position(self._get_filament_position() - step) # Ignore last step movement + return step*i, homed, measured, delta + + # Move filament from the extruder gears (entrance) to the nozzle + # Returns any homing distance for automatic calibration logic + def _load_extruder(self, extruder_only=False): + with self.wrap_action(self.ACTION_LOADING_EXTRUDER): + self.log_debug("Loading filament into extruder") + self._set_filament_direction(self.DIRECTION_LOAD) + + # Important to wait for filaments with wildy different print temps. In practice, the time taken + # to perform a swap should be adequate to reach the target temp but better safe than sorry + self._ensure_safe_extruder_temperature(wait=True) + homing_movement = None + + has_tension = self.sensor_manager.has_sensor(self.SENSOR_TENSION) + has_compression = self.sensor_manager.has_sensor(self.SENSOR_COMPRESSION) + has_proportional = self.sensor_manager.has_sensor(self.SENSOR_PROPORTIONAL) + has_toolhead = self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD) + + synced = not extruder_only + if synced: + self.selector.filament_drive() + speed = self.extruder_sync_load_speed + motor = "gear+extruder" + else: + self.selector.filament_release() + speed = self.extruder_load_speed + motor = "extruder" + + fhomed = False + if has_toolhead: + # With toolhead sensor for accuracy we always first home to toolhead sensor past the extruder entrance + # The remaining load distance is relative to the toolhead sensor + if self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD): + raise MmuError("Possible toolhead sensor malfunction - filament detected before it entered extruder") + self.log_debug("Homing up to %.1fmm to toolhead sensor%s" % (self.toolhead_homing_max, (" (synced)" if synced else ""))) + actual,fhomed,measured,_ = self.trace_filament_move("Homing to toolhead sensor", self.toolhead_homing_max, motor=motor, homing_move=1, endstop_name=self.SENSOR_TOOLHEAD) + if fhomed: + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_TS) + homing_movement = max(actual - (self.toolhead_extruder_to_nozzle - self.toolhead_sensor_to_nozzle), 0) + else: + if self.gate_selected != self.TOOL_GATE_BYPASS: + self._set_filament_pos_state(self.FILAMENT_POS_EXTRUDER_ENTRY) # But could also still be POS_IN_BOWDEN! + else: + # For bypass its best to assume we didn't enter the extruder at all + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + raise MmuError("Failed to reach toolhead sensor after moving %.1fmm" % self.toolhead_homing_max) + + # Length may be reduced by previous unload in filament cutting use case. Ensure reduction is used only one time + d = self.toolhead_sensor_to_nozzle if has_toolhead else self.toolhead_extruder_to_nozzle + length = max(d - self.filament_remaining - self.toolhead_residual_filament - self.toolhead_ooze_reduction - self.toolchange_retract, 0) + + # If we have a compression sensor indicating compression we can detect failure in the critical extruder entrance transition + # by performing the initial load with just the extruder motor and checking that the sensor un-triggers before continuing + if ( + self.gate_selected != self.TOOL_GATE_BYPASS + and self.toolhead_entry_tension_test + and synced + and not has_toolhead + and self.sensor_manager.check_sensor(self.SENSOR_COMPRESSION) + ): + max_range = self.sync_feedback_manager.sync_feedback_buffer_maxrange * 2 # Arbitary but buffer_maxrange is not enough to overcome bowden slack + if length > max_range: + self.log_debug("Monitoring extruder entrance transition for up to %.1fmm..." % max_range) + actual,success = self.sync_feedback_manager.adjust_filament_tension(use_gear_motor=False, max_move=max_range) + if success: + length -= actual + else: + self._set_filament_pos_state(self.FILAMENT_POS_EXTRUDER_ENTRY) # But could also still be POS_IN_BOWDEN! + raise MmuError("Failed to load filament passed the extruder entrance (sync-feedback buffer didn't detect neutral tension)") + + self.log_debug("Loading last %.1fmm to the nozzle..." % length) + _,_,measured,delta = self.trace_filament_move("Loading filament to nozzle", length, speed=speed, motor=motor, wait=True) + self._set_filament_remaining(0.) + + # Encoder based validation test to validate the filament was picked up by extruder. This runs if we are + # short of deterministic sensors and test makes sense + if ( + self.gate_selected != self.TOOL_GATE_BYPASS + and self._can_use_encoder() + and not fhomed + and not extruder_only + ): + self.log_debug("Total measured movement: %.1fmm, total delta: %.1fmm" % (measured, delta)) + if measured < self.encoder_min: + raise MmuError("Move to nozzle failed (encoder didn't sense any movement). Extruder may not have picked up filament or filament did not find homing sensor") + elif delta > length * (self.toolhead_move_error_tolerance / 100.): + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER) + raise MmuError("Move to nozzle failed (encoder didn't sense sufficient movement). Extruder may not have picked up filament or filament did not find homing sensor") + + # Make post load filament tension adjustments for reliability. If encoder is fitted, the "post_load_tighten" + # will aid reliability in subsequent clog detection (and takes prescedence), else "post_load_tention_adjust" will try + # to neutralize the filament tension. Don't run on bypass gate. + if ( + self.gate_selected != self.TOOL_GATE_BYPASS + and not extruder_only + ): + if ( + self.toolhead_post_load_tighten + and not self.sync_to_extruder + and self._can_use_encoder() + and self.sync_feedback_manager.flowguard_encoder_mode + ): + # Tightening move to prevent erroneous encoder clog detection/runout if gear stepper is not synced with extruder + with self.wrap_gear_current(percent=50, reason="to tighten filament in bowden"): + # Filament will already be gripped so perform fixed MMU only retract + pullback = min(self.encoder_sensor.get_clog_detection_length() * self.toolhead_post_load_tighten / 100, 15) # % of current clog detection length or 15mm min + _,_,measured,delta = self.trace_filament_move("Tighening filament in bowden", -pullback, motor="gear", wait=True) + self.log_info("Filament tightened by %.1fmm to prevent false clog detection" % pullback) + + elif ( + self.toolhead_post_load_tension_adjust + and (self.sync_to_extruder or self.sync_purge) + and (has_tension or has_compression or has_proportional) + and self.sync_feedback_manager.is_enabled() + ): + # Try to put filament in neutral tension by centering between sensors + # Two methods are available based on switch only sensors or proportional feedback + actual,success = self.sync_feedback_manager.adjust_filament_tension() + if success: + self.log_info("Filament tension in bowden successfully relaxed") + else: + self.log_warning("Unsuccessful in relaxing filament tension after adjusting %.1fmm" % actual) + + self._random_failure() # Testing + self.movequeues_wait() + self._set_filament_pos_state(self.FILAMENT_POS_LOADED) + self.log_debug("Filament should be loaded to nozzle") + return homing_movement # Will only have value if we have toolhead sensor + + # Extract filament past extruder gear (to end of bowden). Assume that tip has already been formed + # and we are parked somewhere in the extruder either by slicer or by stand alone tip creation + # But be careful: + # A poor tip forming routine or slicer could have popped the filament out of the extruder already + # Ending point is either the exit of the extruder or at the extruder (entry) endstop if fitted + # Return True if we were synced + def _unload_extruder(self, extruder_only=False, validate=True): + with self.wrap_action(self.ACTION_UNLOADING_EXTRUDER): + self.log_debug("Extracting filament from extruder") + self._set_filament_direction(self.DIRECTION_UNLOAD) + + self._ensure_safe_extruder_temperature(wait=False) + + synced = self.selector.get_filament_grip_state() == self.FILAMENT_DRIVE_STATE and not extruder_only + if synced: + self.selector.filament_drive() + speed = self.extruder_sync_unload_speed + motor = "gear+extruder" + else: + self.selector.filament_release() + speed = self.extruder_unload_speed + motor = "extruder" + + fhomed = False + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY) and not extruder_only: + # BEST Strategy: Extruder exit movement leveraging extruder entry sensor. Must be synced + synced = True + self.selector.filament_drive() + speed = self.extruder_sync_unload_speed + motor = "gear+extruder" + + if not self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY): + if self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD): + raise MmuError("Toolhead or extruder sensor failure. Extruder sensor reports no filament but toolhead sensor is still triggered") + else: + self.log_warning("Warning: Filament was not detected by extruder (entry) sensor at start of extruder unload\nWill attempt to continue...") + fhomed = True # Assumption + else: + hlength = self.toolhead_extruder_to_nozzle + self.toolhead_entry_to_extruder + self.toolhead_unload_safety_margin - self.toolhead_residual_filament - self.toolhead_ooze_reduction - self.toolchange_retract + self.log_debug("Reverse homing up to %.1fmm off extruder sensor (synced) to exit extruder" % hlength) + _,fhomed,_,_ = self.trace_filament_move("Reverse homing off extruder sensor", -hlength, motor=motor, homing_move=-1, endstop_name=self.SENSOR_EXTRUDER_ENTRY) + + if not fhomed: + raise MmuError("Failed to reach extruder entry sensor after moving %.1fmm" % hlength) + else: + validate = False + # We know exactly where end of filament is so true up + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_ENTRY) + self._set_filament_position(-(self.toolhead_extruder_to_nozzle + self.toolhead_entry_to_extruder)) + + # TODO There have been reports of this failing, perhaps because of klipper's late update of sensor state? Maybe query_endstop instead + # So former MmuError() has been changed to error message + if self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD): + self.log_warning("Warning: Toolhead sensor still reports filament is present in toolhead! Possible sensor malfunction\nWill attempt to continue...") + + else: + if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + # NEXT BEST: With toolhead sensor we first home to toolhead sensor. Optionally synced + if not self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD): + self.log_warning("Warning: Filament was not detected in extruder by toolhead sensor at start of extruder unload\nWill attempt to continue...") + fhomed = True # Assumption + else: + hlength = self.toolhead_sensor_to_nozzle + self.toolhead_unload_safety_margin - self.toolhead_residual_filament - self.toolhead_ooze_reduction - self.toolchange_retract + self.log_debug("Reverse homing up to %.1fmm off toolhead sensor%s" % (hlength, (" (synced)" if synced else ""))) + _,fhomed,_,_ = self.trace_filament_move("Reverse homing off toolhead sensor", -hlength, motor=motor, homing_move=-1, endstop_name=self.SENSOR_TOOLHEAD) + if not fhomed: + raise MmuError("Failed to reach toolhead sensor after moving %.1fmm" % hlength) + else: + validate = False + # We know exactly where end of filament is so true up + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_TS) + self._set_filament_position(-self.toolhead_sensor_to_nozzle) + + # Finish up with regular extruder exit movement. Optionally synced + length = max(0, self.toolhead_extruder_to_nozzle + self._get_filament_position()) + self.toolhead_unload_safety_margin + self.log_debug("Unloading last %.1fmm to exit the extruder%s" % (length, " (synced)" if synced else "")) + _,_,measured,delta = self.trace_filament_move("Unloading extruder", -length, speed=speed, motor=motor, wait=True) + + # Best guess of filament position is right at extruder entrance or just beyond if synced + if synced: + self._set_filament_position(-(self.toolhead_extruder_to_nozzle + self.toolhead_unload_safety_margin)) + else: + self._set_filament_position(-self.toolhead_extruder_to_nozzle) + + # Encoder based validation test if it has high chance of being useful + # NOTE: This check which used to raise MmuError() is triping many folks up because they have poor tip forming + # logic so just log error and continue. This disguises the root cause problem but will make folks happier + # Not performed for slicer tip forming (validate=True) because everybody is ejecting the filament! + if validate and self._can_use_encoder() and length > self.encoder_move_step_size and not extruder_only and self.gate_selected != self.TOOL_GATE_BYPASS: + self.log_debug("Total measured movement: %.1fmm, total delta: %.1fmm" % (measured, delta)) + msg = None + if measured < self.encoder_min: + msg = "any" + elif synced and delta > length * (self.toolhead_move_error_tolerance / 100.): + msg = "suffient" + if msg: + self.log_warning("Warning: Encoder not sensing %s movement during final extruder retraction move\nConcluding filament either stuck in the extruder, tip forming erroneously completely ejected filament or filament was not fully loaded\nWill attempt to continue..." % msg) + + self._set_filament_pos_state(self.FILAMENT_POS_END_BOWDEN) + + self._random_failure() # Testing + self.movequeues_wait() + self.log_debug("Filament should be out of extruder") + return synced + + +############################################## +# LOAD / UNLOAD SEQUENCES AND FILAMENT TESTS # +############################################## + + def load_sequence(self, bowden_move=None, skip_extruder=False, purge=None, extruder_only=False): + self.movequeues_wait() + + bowden_length = self.calibration_manager.get_bowden_length() # -1 if not calibrated + if bowden_move is None: + bowden_move = bowden_length + + if bowden_move > bowden_length and bowden_length >= 0: + bowden_move = bowden_length + self.log_warning("Warning: Restricting bowden load length to calibrated value of %.1fmm" % bowden_length) + + full = bowden_move == bowden_length + calibrating = bowden_length < 0 and not extruder_only + macros_and_track = not extruder_only and full + + self._set_filament_direction(self.DIRECTION_LOAD) + self._initialize_filament_position(dwell=None) + + try: + home = False + if not extruder_only: + current_action = self._set_action(self.ACTION_LOADING) + if full: + home = self._must_home_to_extruder() or calibrating + else: + skip_extruder = True + + if macros_and_track: + self._track_time_start('load') + # PRE_LOAD user defined macro + with self._wrap_track_time('pre_load'): + self.wrap_gcode_command(self.pre_load_macro, exception=True, wait=True) + + self.log_info("Loading %s..." % ("extruder" if extruder_only else "filament")) + if not extruder_only: + self._display_visual_state() + + homing_movement = None # Track how much homing is done for calibrated bowden length optimization + deficit = 0. # Amount of homing that would be expected (because bowden load is shortened) + bowden_move_ratio = 0. # Track mismatch in moved vs measured bowden distance + overshoot = 0. + calibrated_bowden_length = None + start_filament_pos = self.filament_pos + + # Note: Conditionals deliberately coded this way to match macro alternative + if self.gcode_load_sequence and not calibrating: + self.log_debug("Calling external user defined loading sequence macro") + self.wrap_gcode_command("%s FILAMENT_POS=%d LENGTH=%.1f FULL=%d HOME_EXTRUDER=%d SKIP_EXTRUDER=%d EXTRUDER_ONLY=%d" % (self.load_sequence_macro, start_filament_pos, bowden_move, int(full), int(home), int(skip_extruder), int(extruder_only)), exception=True) + + elif extruder_only: + if start_filament_pos < self.FILAMENT_POS_EXTRUDER_ENTRY: + _ = self._load_extruder(extruder_only=True) + else: + self.log_debug("Assertion failure: Unexpected state %d in load_sequence(extruder_only=True)" % start_filament_pos) + raise MmuError("Cannot load extruder because already in extruder. Unload first") + + elif start_filament_pos >= self.FILAMENT_POS_EXTRUDER_ENTRY: + self.log_debug("Assertion failure: Unexpected state %d in load_sequence()" % start_filament_pos) + raise MmuError("Cannot load because already in extruder. Unload first") + + else: + if start_filament_pos <= self.FILAMENT_POS_UNLOADED: + overshoot = self._load_gate() + + if calibrating: + if self.extruder_homing_endstop in [self.SENSOR_EXTRUDER_NONE, self.SENSOR_EXTRUDER_COLLISION]: + raise MmuError("Auto calibration is not possible with 'extruder_homing_endstop: %s'" % self.SENSOR_EXTRUDER_NONE) + + self.log_warning("Auto calibrating bowden length on gate %d using %s as gate reference point" % (self.gate_selected, self._gate_homing_string())) + if self.sensor_manager.check_sensor(self.extruder_homing_endstop): + raise MmuError("The %s sensor triggered before homing. Check filament and sensor operation" % self.extruder_homing_endstop) + + hm, extra = self._home_to_extruder(self.bowden_homing_max) + if hm is None: + raise MmuError("Failed to auto calibrate bowden because unable to home to extruder after moving %.1fmm" % self.bowden_homing_max) + + calibrated_bowden_length = overshoot + hm + extra + else: + if start_filament_pos < self.FILAMENT_POS_END_BOWDEN: + bowden_move_ratio, deficit = self._load_bowden(bowden_move, start_pos=overshoot) + + if start_filament_pos < self.FILAMENT_POS_HOMED_EXTRUDER and home: + hm, _ = self._home_to_extruder(self.extruder_homing_max) + if hm is not None: + homing_movement = (homing_movement or 0) + hm + + if not skip_extruder: + hm = self._load_extruder() + if hm is not None: + homing_movement = (homing_movement or 0) + hm + + self.movequeues_wait() + msg = "Load of %.1fmm filament successful" % self._get_filament_position() + if self._can_use_encoder(): + final_encoder_pos = self.get_encoder_distance(dwell=None) + not_seen = self.gate_parking_distance + self._get_encoder_dead_space() + msg += " {1}(adjusted encoder: %.1fmm){0}" % (final_encoder_pos + not_seen) + self.log_info(msg, color=True) + + # Notify manager if calibrating/autotuning + if calibrating: + self.calibration_manager.update_bowden_length(calibrated_bowden_length, console_msg=True) + cdl = self.calibration_manager.calc_clog_detection_length(calibrated_bowden_length) + self.calibration_manager.update_clog_detection_length(cdl, force=True) + + elif full and not extruder_only and not self.gcode_load_sequence: + self.calibration_manager.note_load_telemetry(bowden_move_ratio, homing_movement, deficit) + + # Activate loaded spool in Spoolman + self._spoolman_activate_spool(self.gate_spool_id[self.gate_selected]) + + # Deal with purging + if purge == self.PURGE_SLICER and not skip_extruder: + self.log_debug("Purging expected to be performed by slicer") + + elif purge == self.PURGE_STANDALONE and not skip_extruder: + with self._wrap_track_time('purge'): + + # Restore the expected sync state now before running this macro + self.reset_sync_gear_to_extruder(not extruder_only and self.sync_purge) + + with self.wrap_action(self.ACTION_PURGING): + self.purge_standalone() + + # POST_LOAD user defined macro + if macros_and_track: + with self._wrap_track_time('post_load'): + + # Restore the expected sync state now before running this macro + # (we also must force correction of filament grip for old blobifer/unsynced functionality) + self.reset_sync_gear_to_extruder(not extruder_only and self.sync_purge, force_grip=True) + + if self.has_blobifier: # Legacy blobifer integration. purge_macro now preferred + with self.wrap_action(self.ACTION_PURGING): + self.wrap_gcode_command(self.post_load_macro, exception=True, wait=True) + else: + self.wrap_gcode_command(self.post_load_macro, exception=True, wait=True) + + except MmuError as ee: + self._track_gate_statistics('load_failures', self.gate_selected) + raise MmuError("Load sequence failed because:\n%s" % (str(ee))) + + finally: + self._track_gate_statistics('loads', self.gate_selected) + + if not extruder_only: + self._set_action(current_action) + + if macros_and_track: + self._track_time_end('load') + + def unload_sequence(self, bowden_move=None, check_state=False, form_tip=None, extruder_only=False): + self.movequeues_wait() + + bowden_length = self.calibration_manager.get_bowden_length() # -1 if not calibrated yet + if bowden_length < 0: + bowden_length = self.bowden_homing_max # Special case - if not calibrated then apply the max possible bowden length + + if bowden_move is None: + bowden_move = bowden_length + + if bowden_move > bowden_length and bowden_length >= 0: + bowden_move = bowden_length + self.log_warning("Warning: Restricting bowden unload length to calibrated value of %.1fmm" % bowden_length) + + calibrated = bowden_move >= 0 + full = bowden_move == bowden_length + macros_and_track = not extruder_only and full + runout = self.is_handling_runout + + self._set_filament_direction(self.DIRECTION_UNLOAD) + self._initialize_filament_position(dwell=None) + + if check_state or self.filament_pos == self.FILAMENT_POS_UNKNOWN: + # Let's determine where filament is and reset state before continuing + self.recover_filament_pos(message=True) + + if self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_debug("Filament already ejected") + return + + try: + if not extruder_only: + current_action = self._set_action(self.ACTION_UNLOADING) + + # Deactivate spool immediately before tip forming/cutting + # Tip forming/cutting macros use the extruder to execute, hence + # any retraction / de retraction moves are accounted in Spoolman. + # By de-activating early, the retraction performed from the macro + # is deliberately not accounted in spoolman + self._spoolman_activate_spool(0) + + # Run PRE_UNLOAD user defined macro + if macros_and_track: + self._track_time_start('unload') + with self._wrap_track_time('pre_unload'): + self.wrap_gcode_command(self.pre_unload_macro, exception=True, wait=True) + + self.log_info("Unloading %s..." % ("extruder" if extruder_only else "filament")) + if not extruder_only: + self._display_visual_state() + + synced_extruder_unload = False + park_pos = 0. + do_form_tip = form_tip if form_tip is not None else self.FORM_TIP_STANDALONE # Default to standalone + if do_form_tip == self.FORM_TIP_SLICER: + # Slicer was responsible for the tip, but the user must set the slicer_tip_park_pos + park_pos = self.slicer_tip_park_pos + self._set_filament_position(-park_pos) + if park_pos == 0.: + self.log_error("Tip forming performed by slicer but 'slicer_tip_park_pos' not set") + else: + self.log_debug("Tip forming performed by slicer, park_pos set to %.1fmm" % park_pos) + + elif do_form_tip == self.FORM_TIP_STANDALONE and (self.filament_pos >= self.FILAMENT_POS_IN_EXTRUDER or runout): + with self._wrap_track_time('form_tip'): + # Extruder only in runout case to give filament best chance to reach gear + detected = self.form_tip_standalone(extruder_only=(extruder_only or runout)) + park_pos = self._get_filament_position() + + # If handling runout warn if we don't see any filament near the gate + if runout and ( + self.sensor_manager.check_any_sensors_before(self.FILAMENT_POS_HOMED_GATE, self.gate_selected) is False or + (self.has_encoder() and self.get_encoder_distance() == 0) + ): + self.log_warning("Warning: Filament not seen near gate after tip forming move. Unload may not be possible") + + self.wrap_gcode_command(self.post_form_tip_macro, exception=True, wait=True) + + # Note: Conditionals deliberately coded this way to match macro alternative + homing_movement = None # Track how much homing is done for calibrated bowden length optimization + deficit = 0. # Amount of homing that would be expected (because bowden load is shortened) + bowden_move_ratio = 0. # Track mismatch in moved vs measured bowden distance + start_filament_pos = self.filament_pos + unload_to_buffer = (start_filament_pos >= self.FILAMENT_POS_END_BOWDEN and not extruder_only) + + if self.gcode_unload_sequence and calibrated: + self.log_debug("Calling external user defined unloading sequence macro") + self.wrap_gcode_command( + "%s FILAMENT_POS=%d LENGTH=%.1f EXTRUDER_ONLY=%d PARK_POS=%.1f" % ( + self.unload_sequence_macro, + start_filament_pos, + bowden_move, + extruder_only, + park_pos + ), + exception=True + ) + + elif extruder_only: + if start_filament_pos >= self.FILAMENT_POS_EXTRUDER_ENTRY: + synced_extruder_unload = self._unload_extruder(extruder_only=True, validate=do_form_tip == self.FORM_TIP_STANDALONE) + else: + self.log_debug("Assertion failure: Unexpected state %d in unload_sequence(extruder_only=True)" % start_filament_pos) + raise MmuError("Cannot unload extruder because filament not detected in extruder!") + + elif start_filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_debug("Assertion failure: Unexpected state %d in unload_sequence()" % start_filament_pos) + raise MmuError("Cannot unload because already unloaded!") + + else: + if start_filament_pos >= self.FILAMENT_POS_EXTRUDER_ENTRY: + # Exit extruder, fast unload of bowden, then slow unload to gate + synced_extruder_unload = self._unload_extruder(validate=do_form_tip == self.FORM_TIP_STANDALONE) + + if ( + (start_filament_pos >= self.FILAMENT_POS_END_BOWDEN and calibrated) or + (start_filament_pos >= self.FILAMENT_POS_HOMED_GATE and not full) + ): + # Fast unload of bowden, then unload gate + bowden_move_ratio = self._unload_bowden(bowden_move) + homing_movement, deficit = self._unload_gate() + + elif start_filament_pos >= self.FILAMENT_POS_HOMED_GATE: + # We have to do slow unload because we don't know exactly where we are. We use + # full bowden length or max possible length if bowden is uncalibrated + _,_ = self._unload_gate(homing_max=bowden_move if calibrated else self.bowden_homing_max) + + # Set future "from buffer" flag (also used for faster loading speed) + if unload_to_buffer and self.gate_status[self.gate_selected] != self.GATE_EMPTY: + self._set_gate_status(self.gate_selected, self.GATE_AVAILABLE_FROM_BUFFER) + + # If runout then over unload to prevent accidental reload + if runout: + self._eject_from_gate() + +# Currently disabled because it results in servo "flutter" that users don't like +# # Encoder based validation test +# if self._can_use_encoder(): +# movement = self.selector.filament_release(measure=True) +# if movement > self.encoder_min: +# self._set_filament_pos_state(self.FILAMENT_POS_UNKNOWN) +# self.log_trace("Encoder moved %.1fmm when filament was released!" % movement) +# raise MmuError("Encoder sensed movement when the servo was released\nConcluding filament is stuck somewhere") + + self.movequeues_wait() + msg = "Unload of %.1fmm filament successful" % self._get_filament_position() + if self._can_use_encoder(): + final_encoder_pos = self.get_encoder_distance(dwell=None) + not_seen = self.gate_parking_distance + self._get_encoder_dead_space() + (self.toolhead_unload_safety_margin if not synced_extruder_unload else 0.) + msg += " {1}(adjusted encoder: %.1fmm){0}" % -(final_encoder_pos + not_seen) + self.log_info(msg, color=True) + + # Notify autotune manager + if full and not extruder_only and not self.gcode_unload_sequence: + self.calibration_manager.note_unload_telemetry(bowden_move_ratio, homing_movement, deficit) + + # POST_UNLOAD user defined macro + if macros_and_track: + with self._wrap_track_time('post_unload'): + + # Restore the expected sync state now before running this macro + self.reset_sync_gear_to_extruder(not extruder_only and self.sync_to_extruder) + + if self.has_mmu_cutter: + with self.wrap_action(self.ACTION_CUTTING_FILAMENT): + self.wrap_gcode_command(self.post_unload_macro, exception=True, wait=True) + else: + self.wrap_gcode_command(self.post_unload_macro, exception=True, wait=True) + + except MmuError as ee: + self._track_gate_statistics('unload_failures', self.gate_selected) + raise MmuError("Unload sequence failed because:\n%s" % (str(ee))) + + finally: + self._track_gate_statistics('unloads', self.gate_selected) + + if not extruder_only: + self._set_action(current_action) + + if macros_and_track: + self._track_time_end('unload') + + # Form tip prior to extraction from the extruder. This can take the form of shaping the filament or could simply + # activate a filament cutting mechanism. Sets filament position based on park pos + # Returns True if filament is detected + def form_tip_standalone(self, extruder_only=False): + self.movequeues_wait() + + # Pre check to validate the presence of filament in the extruder and case where we don't need to form tip + filament_initially_present = self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) + if filament_initially_present is False: + self.log_debug("Tip forming skipped because no filament was detected") + + if self.filament_pos == self.FILAMENT_POS_LOADED: + self._set_filament_pos_state(self.FILAMENT_POS_EXTRUDER_ENTRY) + else: + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + + self._set_filament_position(-self.toolhead_extruder_to_nozzle) + return False + + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.form_tip_macro, None) + if gcode_macro is None: + raise MmuError("Filament tip forming macro '%s' not found" % self.form_tip_macro) + + with self.wrap_action(self.ACTION_CUTTING_TIP if self.has_toolhead_cutter else self.ACTION_FORMING_TIP): + sync = self.reset_sync_gear_to_extruder(not extruder_only and self.sync_form_tip) + self._ensure_safe_extruder_temperature(wait=True) + + # Perform the tip forming move and establish park_pos + initial_encoder_position = self.get_encoder_distance() + park_pos, remaining, reported = self._do_form_tip() + measured = self.get_encoder_distance(dwell=None) - initial_encoder_position + self._set_filament_remaining(remaining, self.gate_color[self.gate_selected] if self.gate_selected != self.TOOL_GATE_UNKNOWN else '') + + # Encoder based validation test + detected = True # Start with assumption that filament was present + if self._can_use_encoder() and not reported: + # Logic to try to validate success and update presence of filament based on movement + if filament_initially_present is True: + # With encoder we might be able to check for clog now + if not measured > self.encoder_min: + raise MmuError("No encoder movement: Concluding filament is stuck in extruder") + else: + # Couldn't determine if we initially had filament at start (lack of sensors) + if not measured > self.encoder_min: + # No movement. We can be confident we are/were empty + detected = False + elif sync: + # A further test is needed to see if the filament is actually in the extruder + detected, moved = self.test_filament_still_in_extruder_by_retracting() + park_pos += moved + + self._set_filament_position(-park_pos) + self.set_encoder_distance(initial_encoder_position + park_pos) + + if detected or extruder_only: + # Definitely in extruder + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER) + else: + # No detection. Best to assume we are somewhere in bowden for defensive unload + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + + return detected + + def _do_form_tip(self, test=False): + with self._wrap_extruder_current(self.extruder_form_tip_current, "for tip forming move"): + initial_mcu_pos = self.mmu_extruder_stepper.stepper.get_mcu_position() + initial_encoder_position = self.get_encoder_distance() + + with self._wrap_pressure_advance(0., "for tip forming"): + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.form_tip_macro, "_MMU_FORM_TIP") + self.log_info("Forming tip...") + self.wrap_gcode_command("%s %s" % (self.form_tip_macro, "FINAL_EJECT=1" if test else ""), exception=True, wait=True) + + final_mcu_pos = self.mmu_extruder_stepper.stepper.get_mcu_position() + stepper_movement = (initial_mcu_pos - final_mcu_pos) * self.mmu_extruder_stepper.stepper.get_step_dist() + measured = self.get_encoder_distance(dwell=None) - initial_encoder_position + park_pos = gcode_macro.variables.get("output_park_pos", -1) + try: + park_pos = float(park_pos) + except ValueError as e: + self.log_error("Reported 'output_park_pos: %s' could not be parsed: %s" % (park_pos, str(e))) + park_pos = -1 + + reported = False + if park_pos < 0: + # Use stepper movement (tip forming) + filament_remaining = 0. + park_pos = stepper_movement + self.toolhead_residual_filament + self.toolchange_retract + msg = "After tip forming, extruder moved: %.1fmm thus park_pos calculated as %.1fmm (encoder measured %.1fmm total movement)" % (stepper_movement, park_pos, measured) + if test: + self.log_always(msg) + else: + self.log_trace(msg) + else: + # Means the macro reported it (filament cutting) + if park_pos == 0: + self.log_warning("Warning: output_park_pos was reported as 0mm and may not be set correctly\nWill attempt to continue...") + reported = True + filament_remaining = park_pos - stepper_movement - self.toolhead_residual_filament - self.toolchange_retract + msg = "After tip cutting, park_pos reported as: %.1fmm with calculated %.1fmm filament remaining in extruder (extruder moved: %.1fmm, encoder measured %.1fmm total movement)" % (park_pos, filament_remaining, stepper_movement, measured) + if test: + self.log_always(msg) + else: + self.log_trace(msg) + + if not test: + # Important sanity checks to spot misconfiguration + if park_pos > self.toolhead_extruder_to_nozzle: + self.log_warning("Warning: park_pos (%.1fmm) cannot be greater than 'toolhead_extruder_to_nozzle' distance of %.1fmm! Assumming fully unloaded from extruder\nWill attempt to continue..." % (park_pos, self.toolhead_extruder_to_nozzle)) + park_pos = self.toolhead_extruder_to_nozzle + filament_remaining = 0. + + if filament_remaining < 0: + self.log_warning("Warning: Calculated filament remaining after cut is negative (%.1fmm)! Suspect misconfiguration of output_park_pos (%.1fmm).\nWill attempt to continue assuming no cut filament remaining..." % (filament_remaining, park_pos)) + park_pos = 0. + filament_remaining = 0. + + return park_pos, filament_remaining, reported + + def purge_standalone(self): + if self.purge_macro: + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.purge_macro, None) + if gcode_macro: + self.log_info("Purging...") + with self._wrap_extruder_current(self.extruder_purge_current, "for filament purge"): + # The macro to decide on the purge volume, but expect to be based on this. + msg = "Suggested purge volume of %.1fmm%s calculated from:\n" % (self.toolchange_purge_volume, UI_CUBE) + msg += "- toolhead_residual_filament: %.1fmm\n" % self.toolhead_residual_filament + msg += "- filament_remaining (previous cut fragment): %.1fmm\n" % self.filament_remaining + msg += "- slicer purge volume for toolchange %s > %s" % (self.selected_tool_string(self._last_tool), self.selected_tool_string(self._next_tool)) + self.log_debug(msg) + self.wrap_gcode_command(self.purge_macro, exception=True, wait=True) + else: + self.log_warning("Purge macro %s not found" % self.purge_macro) + + +################################# +# FILAMENT MOVEMENT AND CONTROL # +################################# + + # Convenience wrapper around all gear and extruder motor movement that retains sync state, tracks movement and creates trace log + # motor = "gear" - gear motor(s) only on rail + # "gear+extruder" - gear and extruder included on rail + # "extruder" - extruder only on gear rail + # "synced" - gear synced with extruder as in print (homing move not possible) + # + # If homing move then endstop name can be specified. + # "mmu_gate" - at the gate on MMU (when motor includes "gear") + # "mmu_gear_N" - post past the filament drive gear + # "extruder" - just before extruder entrance (motor includes "gear" or "extruder") + # "toolhead" - after extruder entrance (motor includes "gear" or "extruder") + # "mmu_gear_touch" - stallguard on gear (when motor includes "gear", only useful for motor="gear") + # "mmu_ext_touch" - stallguard on nozzle (when motor includes "extruder", only useful for motor="extruder") + # + # All move distances are interpreted as relative + # 'wait' will wait on appropriate move queue(s) after completion of move (forced to True if need encoder reading) + # 'measure' whether we need to wait and measure encoder for movement + # 'encoder_dwell' delay some additional time to ensure we have accurate encoder reading (if encoder fitted and required for measuring) + # + # All moves return: actual (relative), homed, measured, delta; mmu_toolhead.get_position[1] holds absolute position + # + def trace_filament_move(self, trace_str, dist, speed=None, accel=None, motor="gear", homing_move=0, endstop_name="default", track=False, wait=False, encoder_dwell=False, speed_override=True): + encoder_start = self.get_encoder_distance(dwell=encoder_dwell) + pos = self.mmu_toolhead.get_position() + ext_pos = self.toolhead.get_position() + homed = False + actual = dist + delta = 0. + null_rtn = (0., False, 0., 0.) + + if homing_move != 0: + # Check for valid endstop + if endstop_name is None: + endstops = self.gear_rail.get_endstops() + else: + endstop_name = self.sensor_manager.get_mapped_endstop_name(endstop_name) + endstops = self.gear_rail.get_extra_endstop(endstop_name) + if endstops is None: + self.log_error("Endstop '%s' not found" % endstop_name) + return null_rtn + + # Set sensible speeds and accelaration if not supplied + if motor in ["gear"]: + if homing_move != 0: + speed = speed or self.gear_homing_speed + accel = accel or min(self.gear_from_buffer_accel, self.gear_from_spool_accel) + else: + if abs(dist) > self.gear_short_move_threshold: + if dist < 0: + speed = speed or self.gear_unload_speed + accel = accel or self.gear_unload_accel + elif (not self.has_filament_buffer or (self.gate_selected >= 0 and self.gate_status[self.gate_selected] != self.GATE_AVAILABLE_FROM_BUFFER)): + speed = speed or self.gear_from_spool_speed + accel = accel or self.gear_from_spool_accel + else: + speed = speed or self.gear_from_buffer_speed + accel = accel or self.gear_from_buffer_accel + else: + speed = speed or self.gear_short_move_speed + accel = accel or self.gear_short_move_accel + + elif motor in ["gear+extruder", "synced"]: + if homing_move != 0: + speed = speed or min(self.gear_homing_speed, self.extruder_homing_speed) + accel = accel or min(max(self.gear_from_buffer_accel, self.gear_from_spool_accel), self.extruder_accel) + else: + speed = speed or (self.extruder_sync_load_speed if dist > 0 else self.extruder_sync_unload_speed) + accel = accel or min(max(self.gear_from_buffer_accel, self.gear_from_spool_accel), self.extruder_accel) + + elif motor in ["extruder"]: + if homing_move != 0: + speed = speed or self.extruder_homing_speed + accel = accel or self.extruder_accel + else: + speed = speed or (self.extruder_load_speed if dist > 0 else self.extruder_unload_speed) + accel = accel or self.extruder_accel + + else: + self.log_error("Assertion failure: Invalid motor specification '%s'" % motor) + return null_rtn + + # Apply per-gate speed override + if self.gate_selected >= 0 and speed_override: + adjust = self.gate_speed_override[self.gate_selected] / 100. + speed *= adjust + accel *= adjust + + def _set_sync_mode(sync_mode): + self.mmu_toolhead.sync(sync_mode) + if sync_mode == MmuToolHead.GEAR_SYNCED_TO_EXTRUDER: + self._adjust_gear_current(percent=self.sync_gear_current, reason="for extruder synced move") + else: + self._restore_gear_current() # 100% + + with self._wrap_espooler(motor, dist, speed, accel, homing_move): + wait = wait or self._wait_for_espooler # Allow eSpooler wrapper to force wait + + # Gear rail is driving the filament + start_pos = self.mmu_toolhead.get_position()[1] + if motor in ["gear", "gear+extruder", "extruder"]: + _set_sync_mode(MmuToolHead.EXTRUDER_SYNCED_TO_GEAR if motor == "gear+extruder" else MmuToolHead.EXTRUDER_ONLY_ON_GEAR if motor == "extruder" else MmuToolHead.GEAR_ONLY) + if homing_move != 0: + trig_pos = [0., 0., 0., 0.] + hmove = HomingMove(self.printer, endstops, self.mmu_toolhead) + init_ext_mcu_pos = self.mmu_extruder_stepper.stepper.get_mcu_position() # For non-homing extruder or if extruder not on gear rail + init_pos = pos[1] + pos[1] += dist + for _ in range(self.canbus_comms_retries): # HACK: We can repeat because homing move + got_comms_timeout = False # HACK: Logic to try to mask CANbus timeout issues + try: + #initial_mcu_pos = self.mmu_extruder_stepper.stepper.get_mcu_position() + #init_pos = pos[1] + #pos[1] += dist + with self.wrap_accel(accel): + trig_pos = hmove.homing_move(pos, speed, probe_pos=True, triggered=homing_move > 0, check_triggered=True) + homed = True + if self.gear_rail.is_endstop_virtual(endstop_name): + # Stallguard doesn't do well at slow speed. Try to infer move completion + if abs(trig_pos[1] - dist) < 1.0: + homed = False + except self.printer.command_error as e: + # CANbus mcu's often seen to exhibit "Communication timeout" so surface errors to user + if abs(trig_pos[1] - dist) > 0. and "after full movement" not in str(e): + if 'communication timeout' in str(e).lower(): + got_comms_timeout = True + speed *= 0.8 # Reduce speed by 20% + self.log_error("Did not complete homing move: %s" % str(e)) + else: + if self.log_enabled(self.LOG_STEPPER): + self.log_stepper("Did not home: %s" % str(e)) + homed = False + finally: + halt_pos = self.mmu_toolhead.get_position() + ext_actual = (self.mmu_extruder_stepper.stepper.get_mcu_position() - init_ext_mcu_pos) * self.mmu_extruder_stepper.stepper.get_step_dist() + + # Support setup where a non-homing extruder is being used + if motor == "extruder" and not self.homing_extruder: + # This isn't super accurate if extruder isn't (homing) MmuExtruder because doesn't have required endstop, thus this will + # overrun and even move slightly even if already homed. We can only correct the actual gear rail position. + halt_pos[1] += ext_actual + self.mmu_toolhead.set_position(halt_pos) # Correct the gear rail position + + actual = halt_pos[1] - init_pos + if self.log_enabled(self.LOG_STEPPER): + self.log_stepper("%s HOMING MOVE: max dist=%.1f, speed=%.1f, accel=%.1f, endstop_name=%s, wait=%s >> %s" % ( + motor.upper(), dist, speed, accel, endstop_name, wait, + ( + "%s halt_pos=%.1f (rail moved=%.1f, extruder moved=%.1f), " + "start_pos=%.1f, trig_pos=%.1f" + % ( + "HOMED" if homed else "DID NOT HOMED", + halt_pos[1], actual, ext_actual, start_pos, trig_pos[1], + ) + ), + ) + ) + + if not got_comms_timeout: + break + else: + if self.log_enabled(self.LOG_STEPPER): + self.log_stepper("%s MOVE: dist=%.1f, speed=%.1f, accel=%.1f, wait=%s" % (motor.upper(), dist, speed, accel, wait)) + pos[1] += dist + with self.wrap_accel(accel): + self.mmu_toolhead.move(pos, speed) + + # Extruder is driving, gear rail is following + elif motor in ["synced"]: + _set_sync_mode(MmuToolHead.GEAR_SYNCED_TO_EXTRUDER) + if homing_move != 0: + self.log_error("Not possible to perform homing move while synced") + else: + if self.log_enabled(self.LOG_STEPPER): + self.log_stepper("%s MOVE: dist=%.1f, speed=%.1f, accel=%.1f, wait=%s" % (motor.upper(), dist, speed, accel, wait)) + ext_pos[3] += dist + self.toolhead.move(ext_pos, speed) + + self.mmu_toolhead.flush_step_generation() # TTC mitigation (TODO still required?) + self.toolhead.flush_step_generation() # TTC mitigation (TODO still required?) + if wait: + self.movequeues_wait() + + encoder_end = self.get_encoder_distance(dwell=encoder_dwell) + measured = encoder_end - encoder_start + delta = abs(actual) - measured # +ve means measured less than moved, -ve means measured more than moved + if trace_str: + if homing_move != 0: + trace_str += ". Stepper: '%s' %s after moving %.1fmm (of max %.1fmm), encoder measured %.1fmm (delta %.1fmm)" + trace_str = trace_str % (motor, ("homed" if homed else "did not home"), actual, dist, measured, delta) + trace_str += ". Pos: @%.1f, (%.1fmm)" % (self.mmu_toolhead.get_position()[1], encoder_end) + else: + trace_str += ". Stepper: '%s' moved %.1fmm, encoder measured %.1fmm (delta %.1fmm)" + trace_str = trace_str % (motor, dist, measured, delta) + trace_str += ". Pos: @%.1f, (%.1fmm)" % (self.mmu_toolhead.get_position()[1], encoder_end) + self.log_trace(trace_str) + + if self._can_use_encoder() and motor == "gear" and track: + if dist > 0: + self._track_gate_statistics('load_distance', self.gate_selected, dist) + self._track_gate_statistics('load_delta', self.gate_selected, delta) + else: + self._track_gate_statistics('unload_distance', self.gate_selected, -dist) + self._track_gate_statistics('unload_delta', self.gate_selected, delta) + if dist != 0: + quality = abs(1. - delta / dist) + cur_quality = self.gate_statistics[self.gate_selected]['quality'] + if cur_quality < 0: + self.gate_statistics[self.gate_selected]['quality'] = quality + else: + # Average down over 10 swaps + self.gate_statistics[self.gate_selected]['quality'] = (cur_quality * 9 + quality) / 10 + + return actual, homed, measured, delta + + # Used to force accelaration override for homing moves + @contextlib.contextmanager + def wrap_accel(self, accel): + self.mmu_toolhead.get_kinematics().set_accel_limit(accel) + try: + yield self + finally: + self.mmu_toolhead.get_kinematics().set_accel_limit(None) + + # Used to wrap certain unload moves and activate eSpooler. Ensures eSpooler is always stopped + @contextlib.contextmanager + def _wrap_espooler(self, motor, dist, speed, accel, homing_move): + self._wait_for_espooler = False + espooler_operation = self.ESPOOLER_OFF + + if self.has_espooler(): + pwm_value = 0 + if abs(dist) >= self.espooler_min_distance and speed > self.espooler_min_stepper_speed: + if dist > 0 and self.ESPOOLER_ASSIST in self.espooler_operations: + espooler_operation = self.ESPOOLER_ASSIST + elif dist < 0 and self.ESPOOLER_REWIND in self.espooler_operations: + espooler_operation = self.ESPOOLER_REWIND + + if espooler_operation == self.ESPOOLER_OFF: + pwm_value = 0 + elif speed >= self.espooler_max_stepper_speed: + pwm_value = 1 + else: + pwm_value = (speed / self.espooler_max_stepper_speed) ** self.espooler_speed_exponent + + # Reduce assist speed compared to rewind but also apply the "print" minimum + # We want rewind to be faster than assist but never non-functional + if espooler_operation == self.ESPOOLER_ASSIST: + pwm_value = max(pwm_value * (self.espooler_assist_reduced_speed / 100), self.espooler_printing_power / 100) + + if espooler_operation != self.ESPOOLER_OFF: + self._wait_for_espooler = not homing_move + self.espooler.set_operation(self.gate_selected, pwm_value, espooler_operation) + try: + # Note gate_selected doesn't change in this use case, it's just filament movement + yield self + + finally: + self._wait_for_espooler = False + if espooler_operation != self.ESPOOLER_OFF: + self.espooler.set_operation(self.gate_selected, 0, self.ESPOOLER_OFF) + + +############################################## +# GENERAL FILAMENT RECOVERY AND MOVE HELPERS # +############################################## + + # Report on need to recover and necessary calibration + def report_necessary_recovery(self, use_autotune=True): + if not self.check_if_not_calibrated(self.CALIBRATED_ALL, silent=None, use_autotune=use_autotune): + if self.filament_pos != self.FILAMENT_POS_UNLOADED and self.TOOL_GATE_UNKNOWN in [self.gate_selected, self.tool_selected]: + self.log_error("Filament detected but tool/gate is unknown. Plese use MMU_RECOVER GATE=xx to correct state") + elif self.filament_pos not in [self.FILAMENT_POS_LOADED, self.FILAMENT_POS_UNLOADED]: + self.log_error("Filament not detected as either unloaded or fully loaded. Please check and use MMU_RECOVER to correct state or fix before continuing") + + # This is a recovery routine to determine the most conservative location of the filament (for unload purposes) + # Also, ensures that the filament availabilty is updated if filament is found + def recover_filament_pos(self, strict=False, can_heat=True, message=False, silent=False): + if message: + self.log_info("Attempting to recover filament position...") + + ts = self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) + es = self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY) + gs = self.sensor_manager.check_sensor(self.sensor_manager.get_mapped_endstop_name(self.gate_homing_endstop)) + + filament_detected = self.sensor_manager.check_any_sensors_in_path() + looks_loaded = self.sensor_manager.check_all_sensors_in_path() + if not filament_detected: + filament_detected = self.check_filament_in_mmu() # Include encoder detection method + + # Definitely loaded + if ts: + self._set_filament_pos_state(self.FILAMENT_POS_LOADED, silent=silent) + + # Probably loaded: Unless strict we will continue to assume loaded in the absence of sensors to say otherwise + elif not strict and self.filament_pos == self.FILAMENT_POS_LOADED and looks_loaded: + pass + + # Somewhere in extruder + elif filament_detected and can_heat and self.check_filament_in_extruder(): # Encoder based + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER, silent=silent) # Will start from tip forming on unload + elif ts is False and filament_detected and (self.strict_filament_recovery or strict) and can_heat and self.check_filament_in_extruder(): + # This case adds an additional encoder based test to see if filament is still being gripped by extruder + # even though TS doesn't see it. It's a pedantic option so on turned on by strict flag + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER, silent=silent) # Will start from tip forming + + # At extruder entry + elif es: + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_ENTRY, silent=silent) # Allows for fast bowden unload move + + # Parked at gate (when parking distance is not a retract i.e. gs sensor expected to be triggered) + elif gs and filament_detected and self.gate_parking_distance <= 0: + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=silent) + + # Somewhere in bowden + elif gs or filament_detected: + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN, silent=silent) # Prevents fast bowden unload move + + # Sensor sanity check + if self.sensor_manager.check_all_sensors_before(self.FILAMENT_POS_HOMED_GATE, self.gate_selected, loading=False) is False: + sensors = self.sensor_manager.get_sensors_before(self.FILAMENT_POS_HOMED_GATE, self.gate_selected, loading=False) + malfunction = ", ".join(sorted(k for k, v in sensors.items() if v is False)) + self.log_warning("Filament determined to be somewhere in bowden but the following sensors are unexpectedly not triggered: %s\nCheck for further sensor malfunction with MMU_SENSORS command. Also validate the correct gate is selected.\nRe-run MMU_RECOVER when ready" % malfunction) + + # Unloaded + else: + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=silent) + + # If filament is detected then ensure gate status is correct + if self.gate_selected != self.TOOL_GATE_UNKNOWN and filament_detected: + gate_status = self.gate_status[self.gate_selected] + if self.filament_pos >= self.FILAMENT_POS_START_BOWDEN and gate_status < self.GATE_AVAILABLE: + self._set_gate_status(self.gate_selected, self.GATE_AVAILABLE) + elif gate_status == self.GATE_EMPTY: + self._set_gate_status(self.gate_selected, self.GATE_UNKNOWN) + + # Check for filament in MMU using available sensors or encoder + def check_filament_in_mmu(self): + self.log_debug("Checking for filament in MMU...") + detected = self.sensor_manager.check_any_sensors_in_path() + if not detected and self.has_encoder(): + self.selector.filament_drive() + detected = self.buzz_gear_motor() + self.log_debug("Filament %s in encoder after buzzing gear motor" % ("detected" if detected else "not detected")) + if detected is None: + self.log_debug("No sensors configured!") + return detected + + # Check for filament at currently selected gate + def check_filament_in_gate(self): + self.log_debug("Checking for filament at gate...") + detected = self.sensor_manager.check_any_sensors_before(self.FILAMENT_POS_HOMED_GATE, self.gate_selected) + if not detected and self.has_encoder(): + self.selector.filament_drive() + detected = self.buzz_gear_motor() + self.log_debug("Filament %s in encoder after buzzing gear motor" % ("detected" if detected else "not detected")) + if detected is None: + self.log_debug("No sensors configured!") + return detected + + # Return True if filament runout detected by sensors + def check_filament_runout(self): + self.log_debug("Checking for runout...") + runout = self.sensor_manager.check_for_runout() + if runout is None and self.has_encoder(): + self.selector.filament_drive() + detected = not self.buzz_gear_motor() + self.log_debug("Filament %s in encoder after buzzing gear motor" % ("detected" if detected else "not detected")) + runout = not detected + if runout is None: + self.log_debug("No sensors configured!") + return runout + + # Return True/False if detected or None if test not possible + def check_filament_in_extruder(self): + # First double check extruder entry sensor if fitted + es = self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY) + if es is not None: + return es + + # Now toolhead if fitted + ts = self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) + if ts is True: + return True + + # Finally resort to movement test with encoder + detected,_ = self.test_filament_still_in_extruder_by_retracting() + return detected + + # Check for filament in extruder by moving extruder motor. Even with toolhead sensor this can + # happen if the filament is in the short distance from sensor to gears. Requires encoder + # Return True/False if detected or None if test not possible + def test_filament_still_in_extruder_by_retracting(self): + detected = None + measured = 0 + if self.has_encoder() and not self.mmu_machine.filament_always_gripped: + with self._require_encoder(): # Force quality measurement + self.log_info("Checking for possibility of filament still in extruder gears...") + self._ensure_safe_extruder_temperature(wait=False) + self.selector.filament_release() + move = self.encoder_move_step_size + _,_,measured,_ = self.trace_filament_move("Checking extruder", -move, speed=self.extruder_unload_speed, motor="extruder") + detected = measured > self.encoder_min + self.log_debug("Filament %s in extruder" % ("detected" if detected else "not detected")) + return detected, measured + + def buzz_gear_motor(self): + if self.has_encoder(): + with self._require_encoder(): # Force quality measurement + initial_encoder_position = self.get_encoder_distance() + self.trace_filament_move(None, 2.5 * self.encoder_resolution, accel=self.gear_buzz_accel, encoder_dwell=None) + self.trace_filament_move(None, -2.5 * self.encoder_resolution, accel=self.gear_buzz_accel, encoder_dwell=None) + measured = self.get_encoder_distance() - initial_encoder_position + self.log_trace("After buzzing gear motor, encoder measured %.2f" % measured) + self.set_encoder_distance(initial_encoder_position, dwell=None) + return measured > self.encoder_min + else: + self.trace_filament_move(None, 5, accel=self.gear_buzz_accel) + self.trace_filament_move(None, -5, accel=self.gear_buzz_accel) + return None + + + def reset_sync_gear_to_extruder(self, sync_intention, force_grip=False, force_in_print=False, skip_extruder_check=False): + """ + Reset the gear-to-extruder sync state based on MMU type and current state. + + Args: + sync_intention (bool): + Desired sync state during printing, derived from parameters such as + `sync_to_extruder`, `sync_form_tip`, and `sync_purge`. + + force_grip (bool, optional): + If True, forces an immediate filament grip state change + (typically triggers a servo movement). + + force_in_print (bool, optional): + Forces the logic to behave as if the printer is currently in a print. + Primarily used for testing. + + skip_extruder_check (bool, optional): + Normally, syncing only occurs if filament is present in the extruder. + When True, this overrides that check. Used by the + `MMU_SYNC_GEAR_MOTOR` command. + + Returns: + bool: The final sync state that was applied. + """ + bypass_selected = self.gate_selected == self.TOOL_GATE_BYPASS + in_print_context = self.is_in_print(force_in_print) + actively_printing = self.is_printing(force_in_print) + + filament_past_entry = self.filament_pos >= self.FILAMENT_POS_EXTRUDER_ENTRY + extruder_check_ok = filament_past_entry or skip_extruder_check + + always_gripped = self.mmu_machine.filament_always_gripped + standalone_sync_requested = self._standalone_sync + + # In a non-print context we also honor the caller's explicit intention. + wants_sync_out_of_print = always_gripped or standalone_sync_requested or sync_intention + + if bypass_selected: + sync = False + + elif in_print_context: + if actively_printing: + # During active printing, respect the print-time sync setting. + sync = bool(self.sync_to_extruder) + else: + # In print context but not actively printing (e.g., paused/warming), + # sync only if filament is present (or overridden) and sync is needed/requested + sync = extruder_check_ok and (always_gripped or standalone_sync_requested) + + else: + # Not in a print: sync if filament is present (or overridden) and any + # condition requires/requests syncing. + sync = extruder_check_ok and wants_sync_out_of_print + + self.sync_gear_to_extruder(sync, force_grip=force_grip) + return sync + + + def sync_gear_to_extruder(self, sync, gate=None, force_grip=False): + """ + Sync or unsync the gear motor with the extruder, handling filament engagement and current control. + + This method: + - Applies safety guards to prevent syncing when bypass/unknown gates are selected or the + selector is not homed. + - Manages filament grip/release (especially important on type-A designs to avoid "buzz" + and to reduce servo flutter). + - Updates the toolhead sync mode. + - Adjusts gear stepper current while synced, and restores it when unsynced. On multigear + machines, it also ensures the previously-used gear stepper's current is restored when + switching gates. + + Args: + sync (bool): + True to sync the gear motor to the extruder; False to unsync. + + gate (Optional[int]): + Gate to apply current adjustments to. If None, defaults to `self.gate_selected`. + This is optional because on some type-B designs this method may be called before + `self.gate_selected` is finalized. + + force_grip (bool, optional): + If True, forces a filament release action when unsyncing even if release suppression + is enabled higher in the call stack. + + Returns: + None + """ + # Default to current selection; some designs call this before gate selection is finalized. + if gate is None: + gate = self.gate_selected + + bypass_or_unknown_gate = gate < 0 + selector_ready = getattr(self.selector, "is_homed", False) + + # Protect cases where we should not sync (type-B always has a homed selector). + if bypass_or_unknown_gate or not selector_ready: + sync = False + self._standalone_sync = False + + # Filament grip handling (do this before syncing to avoid "buzz" movement on type-A MMUs). + if sync: + self.selector.filament_drive() + else: + # There are situations where we want to be lazy to avoid servo "flutter": + # - `_suppress_release_grip` is True unless we are the outermost caller. + # - `force_grip` can override that suppression. + should_release = force_grip or not self._suppress_release_grip + if should_release: + self.selector.filament_release() + + # Sync / unsync toolhead mode (avoid redundant calls). + desired_sync_mode = MmuToolHead.GEAR_SYNCED_TO_EXTRUDER if sync else None + if desired_sync_mode != self.mmu_toolhead.sync_mode: + self.movequeues_wait() # Safety: likely unnecessary but ensures no queued moves conflict. + self.mmu_toolhead.sync(desired_sync_mode) + + # Current control: + # - While synced, optionally reduce current for the active gear stepper. + # - On multigear systems, restore current on the previously-used gear stepper if gate differs. + # - When unsynced, restore current to 100%. + if sync: + if self.mmu_machine.multigear and gate != self.gate_selected: + self._restore_gear_current() # Restore previous gear stepper to 100% + + self._adjust_gear_current( + gate=gate, + percent=self.sync_gear_current, + reason="for extruder syncing", + ) + else: + self._restore_gear_current() # 100% + + + @contextlib.contextmanager + def wrap_sync_gear_to_extruder(self): + """ + Context manager that protects gear/extruder synchronization, current, + and filament grip state. + + This is intended to be used as the outermost wrapper around "MMU_*" + command handlers that may temporarily alter sync, motor current, or + grip state during a print or standalone operation. + + Behavior: + - Captures the current sync state before entering the context. + - Suppresses filament release in nested calls to prevent servo + "flutter" caused by repeated grip/release transitions. + - Ensures only the outermost wrapper clears suppression. + - Restores the previous sync state on exit (via + `reset_sync_gear_to_extruder`), allowing normal logic to + reconcile grip and current safely. + + Yields: + self: Allows the caller to operate within the protected context. + Example: + with self.wrap_sync_gear_to_extruder(): + self.sync_gear_to_extruder(True) + ... + """ + # Capture current sync state so it can be restored on exit. + previous_sync = ( + self.mmu_toolhead.sync_mode == MmuToolHead.GEAR_SYNCED_TO_EXTRUDER + ) + + # Suppress grip release only at the outermost level. + outermost_wrapper = not self._suppress_release_grip + self._suppress_release_grip = True + + try: + yield self + finally: + # Only the outermost wrapper clears suppression. + if outermost_wrapper: + self._suppress_release_grip = False + + # Restore prior sync state. Logic inside reset_sync_gear_to_extruder + # may consult the global suppression flag when reconciling grip. + self.reset_sync_gear_to_extruder(previous_sync) + + + # ---------- TMC Stepper Current Control ---------- + + @contextlib.contextmanager + def wrap_gear_current(self, percent=100, reason=""): + """ + Run block of logic with gear stepper current set to desired percentage and then + restore to previous setting + """ + prev_percent = self._adjust_gear_current(percent=percent, reason=reason) + self._gear_current_locked = True + try: + yield self + finally: + self._gear_current_locked = False + self._restore_gear_current(percent=prev_percent) + + def _adjust_gear_current(self, gate=None, percent=100, reason="", restore=False): + current_percent = self.gear_percentage_run_current + + if self._gear_current_locked: return current_percent + if gate is None: gate = self.gate_selected + if gate < 0: return current_percent + if not (0 < percent < 200): return current_percent + if not self.gear_tmc: return current_percent + if percent == self.gear_percentage_run_current: return current_percent + + gear_stepper_name = mmu_machine.GEAR_STEPPER_CONFIG + if self.mmu_machine.multigear and gate > 0: + gear_stepper_name = "%s_%d" % (mmu_machine.GEAR_STEPPER_CONFIG, gate) + if restore: + msg = "Restoring MMU %s run current to %d%% ({}A)" % (gear_stepper_name, percent) + else: + msg = "Modifying MMU %s run current to %d%% ({}A) %s" % (gear_stepper_name, percent, reason) + target_current = (self.gear_default_run_current * percent) / 100.0 + self._set_tmc_current(gear_stepper_name, target_current, msg) + self.gear_percentage_run_current = percent # Update global record of current % + return percent + + def _restore_gear_current(self, gate=None, percent=100): + _ = self._adjust_gear_current(gate=gate, percent=percent, restore=True) + + @contextlib.contextmanager + def _wrap_extruder_current(self, percent=100, reason=""): + prev_percent = self._adjust_extruder_current(percent, reason) + try: + yield self + finally: + self._restore_extruder_current(percent=prev_percent) + + def _adjust_extruder_current(self, percent=100, reason="", restore=False): + current_percent = self.extruder_percentage_run_current + + if not self.extruder_tmc: return current_percent + if not (0 < percent < 200): return current_percent + if percent == self.extruder_percentage_run_current: return current_percent + + if restore: + msg = "Restoring extruder stepper run current to %d%% ({}A)" + else: + msg = "Modifying extruder stepper run current to %d%% ({}A) %s" % (percent, reason) + self._set_tmc_current(self.extruder_name, (self.extruder_default_run_current * percent) / 100., msg) + self.extruder_percentage_run_current = percent # Update global record of current % + return percent + + def _restore_extruder_current(self, percent=100): + _ = self._adjust_extruder_current(percent=percent, restore=True) + + def _set_tmc_current(self, stepper, run_current, msg): + self.log_info(msg.format("%.2f" % run_current)) + self.gcode.run_script_from_command("SET_TMC_CURRENT STEPPER=%s CURRENT=%.2f" % (stepper, run_current)) + + + # ---------- Pressure Advance Control ---------- + + @contextlib.contextmanager + def _wrap_pressure_advance(self, pa=0, reason=""): + extruder = self.toolhead.get_extruder() + initial_pa = extruder.get_status(0).get('pressure_advance') + + if initial_pa is None: + yield self + return + + try: + if reason: + self.log_debug("Setting pressure advance %s: %.4f" % (reason, pa)) + self._set_pressure_advance(pa) + + yield self + + finally: + if reason: + self.log_debug("Restoring pressure advance: %.4f" % initial_pa) + self._set_pressure_advance(initial_pa) + + def _set_pressure_advance(self, pa): + self.gcode.run_script_from_command("SET_PRESSURE_ADVANCE ADVANCE=%.4f QUIET=1" % pa) + + + # Logic shared with MMU_TEST_MOVE and _MMU_STEP_MOVE + def _move_cmd(self, gcmd, trace_str, allow_bypass=False): + if self.check_if_disabled(): return (0., False, 0., 0.) + if not allow_bypass and self.check_if_bypass(): return (0., False, 0., 0.) + move = gcmd.get_float('MOVE', 100.) + speed = gcmd.get_float('SPEED', None) + accel = gcmd.get_float('ACCEL', None) + motor = gcmd.get('MOTOR', "gear") + wait = bool(gcmd.get_int('WAIT', 1, minval=0, maxval=1)) # Wait for move to complete (make move synchronous) + if motor not in ["gear", "extruder", "gear+extruder", "synced"]: + raise gcmd.error("Valid motor names are 'gear', 'extruder', 'gear+extruder' or 'synced'") + if motor == "extruder": + self.selector.filament_release() + else: + self.selector.filament_drive() + self.log_debug("Moving '%s' motor %.1fmm..." % (motor, move)) + return self.trace_filament_move(trace_str, move, speed=speed, accel=accel, motor=motor, wait=wait) + + # Logic shared with MMU_TEST_HOMING_MOVE and _MMU_STEP_HOMING_MOVE + def _homing_move_cmd(self, gcmd, trace_str, allow_bypass=False): + if self.check_if_disabled(): return (0., False, 0., 0.) + if not allow_bypass and self.check_if_bypass(): return (0., False, 0., 0.) + endstop = gcmd.get('ENDSTOP', "default") + move = gcmd.get_float('MOVE', 100.) + speed = gcmd.get_float('SPEED', None) + accel = gcmd.get_float('ACCEL', None) # Ignored for extruder led moves + motor = gcmd.get('MOTOR', "gear") + if motor not in ["gear", "extruder", "gear+extruder"]: + raise gcmd.error("Valid motor names are 'gear', 'extruder', 'gear+extruder'") + direction = -1 if move < 0 else 1 + stop_on_endstop = gcmd.get_int('STOP_ON_ENDSTOP', direction, minval=-1, maxval=1) + if abs(stop_on_endstop) != 1: + raise gcmd.error("STOP_ON_ENDSTOP can only be 1 (extrude direction) or -1 (retract direction)") + endstop = self.sensor_manager.get_mapped_endstop_name(endstop) + valid_endstops = list(self.gear_rail.get_extra_endstop_names()) + if endstop not in valid_endstops: + raise gcmd.error("Endstop name '%s' is not valid for motor '%s'. Options are: %s" % (endstop, motor, ', '.join(valid_endstops))) + if self.gear_rail.is_endstop_virtual(endstop) and stop_on_endstop == -1: + raise gcmd.error("Cannot reverse home on virtual (TMC stallguard) endstop '%s'" % endstop) + if motor == "extruder": + self.selector.filament_release() + else: + self.selector.filament_drive() + self.log_debug("Homing '%s' motor to '%s' endstop, up to %.1fmm..." % (motor, endstop, move)) + return self.trace_filament_move(trace_str, move, speed=speed, accel=accel, motor=motor, homing_move=stop_on_endstop, endstop_name=endstop) + + +############################ +# TOOL SELECTION FUNCTIONS # +############################ + + def _record_tool_override(self): + tool = self.tool_selected + if tool >= 0: + current_speed_factor = self.gcode_move.get_status(0)['speed_factor'] + current_extrude_factor = self.gcode_move.get_status(0)['extrude_factor'] + if self.tool_speed_multipliers[tool] != current_speed_factor or self.tool_extrusion_multipliers[tool] != current_extrude_factor: + self.tool_speed_multipliers[tool] = current_speed_factor + self.tool_extrusion_multipliers[tool] = current_extrude_factor + self.log_debug("Saved speed/extrusion multiplier for tool T%d as %d%% and %d%%" % (tool, current_speed_factor * 100, current_extrude_factor * 100)) + + def _restore_tool_override(self, tool): + if tool == self.tool_selected: + current_speed_factor = self.gcode_move.get_status(0)['speed_factor'] + current_extrude_factor = self.gcode_move.get_status(0)['extrude_factor'] + speed_factor = self.tool_speed_multipliers[tool] + extrude_factor = self.tool_extrusion_multipliers[tool] + self.gcode.run_script_from_command("M220 S%d" % (speed_factor * 100)) + self.gcode.run_script_from_command("M221 S%d" % (extrude_factor * 100)) + if current_speed_factor != speed_factor or current_extrude_factor != extrude_factor: + self.log_debug("Restored speed/extrusion multiplier for tool T%d as %d%% and %d%%" % (tool, speed_factor * 100, extrude_factor * 100)) + + def _set_tool_override(self, tool, speed_percent, extrude_percent): + if tool == -1: + for i in range(self.num_gates): + if speed_percent is not None: + self.tool_speed_multipliers[i] = speed_percent / 100 + if extrude_percent is not None: + self.tool_extrusion_multipliers[i] = extrude_percent / 100 + self._restore_tool_override(i) + if speed_percent is not None: + self.log_debug("Set speed multiplier for all tools as %d%%" % speed_percent) + if extrude_percent is not None: + self.log_debug("Set extrusion multiplier for all tools as %d%%" % extrude_percent) + else: + if speed_percent is not None: + self.tool_speed_multipliers[tool] = speed_percent / 100 + self.log_debug("Set speed multiplier for tool T%d as %d%%" % (tool, speed_percent)) + if extrude_percent is not None: + self.tool_extrusion_multipliers[tool] = extrude_percent / 100 + self.log_debug("Set extrusion multiplier for tool T%d as %d%%" % (tool, extrude_percent)) + self._restore_tool_override(tool) + + # Primary method to select and loads tool. Assumes we are unloaded + def _select_and_load_tool(self, tool, purge=None): + + try: + # Determine purge volume for toolchange/load. Valid only during toolchange/load operation + self.toolchange_purge_volume = self._calc_purge_volume(self._last_tool, tool) + + self.log_debug('Loading tool %s...' % self.selected_tool_string(tool)) + self.select_tool(tool) + gate = self.ttg_map[tool] if tool >= 0 else self.gate_selected + if self.gate_status[gate] == self.GATE_EMPTY: + if self.endless_spool_enabled and self.endless_spool_on_load: + next_gate, msg = self._get_next_endless_spool_gate(tool, gate) + if next_gate == -1: + raise MmuError("Gate %d is empty!\nNo alternatives gates available after checking %s" % (gate, msg)) + + self.log_error("Gate %d is empty! Checking for alternative gates %s" % (gate, msg)) + self.log_info("Remapping %s to gate %d" % (self.selected_tool_string(tool), next_gate)) + self._remap_tool(tool, next_gate) + self.select_tool(tool) + else: + raise MmuError("Gate %d is empty (and EndlessSpool on load is disabled)\nLoad gate, remap tool to another gate or correct state with 'MMU_CHECK_GATE GATE=%d' or 'MMU_GATE_MAP GATE=%d AVAILABLE=1'" % (gate, gate, gate)) + + self.load_sequence(purge=purge) + self._restore_tool_override(self.tool_selected) # Restore M220 and M221 overrides + + finally: + self.toolchange_purge_volume = 0. + + # Primary method to unload current tool but retain selection + def _unload_tool(self, form_tip=None, prev_tool=None): + if self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_info("Tool already unloaded") + return + + self.log_debug("Unloading tool %s" % self.selected_tool_string()) + # Use the actual tool that was in use *before* this toolchange began + # Falls back to current selection if not provided (backwards compatible) + self._set_last_tool(self.tool_selected if prev_tool is None else prev_tool) + self._record_tool_override() # Remember M220 and M221 overrides + self.unload_sequence(form_tip=form_tip) + + def _auto_home(self, tool=0): + if not self.selector.is_homed or self.tool_selected == self.TOOL_GATE_UNKNOWN: + self.log_info("MMU selector not homed, will home before continuing") + self.home(tool) + elif self.filament_pos == self.FILAMENT_POS_UNKNOWN and self.selector.is_homed: + self.recover_filament_pos(message=True) + + # Important to always inform use of "toolchange" operation is case there is an error and manual recovery is necessary + def _note_toolchange(self, m117_msg): + self._last_toolchange = m117_msg + if self.log_m117_messages: + self.gcode.run_script_from_command("M117 %s" % m117_msg) + + # Tell the sequence macros about where to move to next + def _set_next_position(self, next_pos): + if next_pos: + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=next_xy VALUE=%s,%s" % (self.park_macro, next_pos[0], next_pos[1])) + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=next_pos VALUE=True" % self.park_macro) + else: + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=next_pos VALUE=False" % self.park_macro) + + +### TOOL AND GATE SELECTION ###################################################### + + def home(self, tool, force_unload = None): + if self.check_if_bypass(): return + self._set_tool_selected(self.TOOL_GATE_UNKNOWN) + self.selector.home(force_unload=force_unload) + if tool >= 0: + self.select_tool(tool) + elif tool == self.TOOL_GATE_BYPASS: + self.select_bypass() + + def select_gate(self, gate): + try: + + if gate == self.gate_selected: + self.selector.select_gate(gate) # Always give selector a chance to fix position + else: + self._next_gate = gate # Valid only during the gate selection process + _prev_gate = self.gate_selected + self.selector.select_gate(gate) + self._set_gate_selected(gate) + self.led_manager.gate_map_changed(_prev_gate) + self.led_manager.gate_map_changed(gate) + + except MmuError as ee: + self.unselect_gate() + raise ee + finally: + self._next_gate = None + + def unselect_gate(self): + self.selector.select_gate(self.TOOL_GATE_UNKNOWN) # Required for type-B MMU's to unsync + self._set_gate_selected(self.TOOL_GATE_UNKNOWN) + + def select_tool(self, tool): + if tool < 0 or tool >= self.num_gates: + self.log_always("Tool %s does not exist" % self.selected_tool_string(tool)) + return + + gate = self.ttg_map[tool] + if tool == self.tool_selected and gate == self.gate_selected: + self.select_gate(gate) # Some selectors need to be re-synced + return + + self.log_debug("Selecting tool %s on gate %d..." % (self.selected_tool_string(tool), gate)) + self.select_gate(gate) + self._set_tool_selected(tool) + self.log_info("Tool %s enabled%s" % (self.selected_tool_string(tool), (" on gate %d" % gate) if tool != gate else "")) + + def select_bypass(self): + if self.tool_selected == self.TOOL_GATE_BYPASS and self.gate_selected == self.TOOL_GATE_BYPASS: return + self.log_info("Selecting filament bypass...") + self.select_gate(self.TOOL_GATE_BYPASS) + self._set_tool_selected(self.TOOL_GATE_BYPASS) + self._set_filament_direction(self.DIRECTION_LOAD) + self.log_info("Bypass enabled") + + def _set_tool_selected(self, tool): + if tool != self.tool_selected: + self.tool_selected = tool + self.save_variable(self.VARS_MMU_TOOL_SELECTED, self.tool_selected, write=True) + + def _set_gate_selected(self, gate): + self.gate_selected = gate + + new_unit = self.find_unit_by_gate(gate) + if new_unit != self.unit_selected: + self.unit_selected = new_unit + self.sensor_manager.reset_active_unit(new_unit) + + self.sensor_manager.reset_active_gate(self.gate_selected) # Call after unit_selected is set + self.sync_feedback_manager.set_default_rd() # Will always set rotation_distance + + self.save_variable(self.VARS_MMU_GATE_SELECTED, self.gate_selected, write=True) + self.active_filament = { + 'filament_name': self.gate_filament_name[gate], + 'material': self.gate_material[gate], + 'color': self.gate_color[gate], + 'spool_id': self.gate_spool_id[gate], + 'temperature': self.gate_temperature[gate], + } if gate >= 0 else {} + + # Return unit number for gate + def find_unit_by_gate(self, gate): + unit = self.mmu_machine.get_mmu_unit_by_gate(gate) + if unit: + return unit.unit_index + self.log_debug("Assertion failure: Gate %d has no unit!" % gate) + return 0 + + # Set the active gear stepper rotation distance + def set_gear_rotation_distance(self, rd): + if rd: + self.log_trace("Setting gear motor rotation distance: %.4f" % rd) + if self.gear_rail.steppers: + self.gear_rail.steppers[0].set_rotation_distance(rd) + + def _moonraker_push_lane_data(self, gate_ids = None): + gate_ids = [(i, self.gate_spool_id[i]) for i in range(self.num_gates)] if gate_ids is None else gate_ids + if gate_ids: + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("moonraker_push_lane_data", gate_ids=gate_ids) + except Exception as e: + self.log_debug("Failed to push lane data to Moonraker: %s" % str(e)) + + def _moonraker_sync_lane_data(self): + # Push all current gate data to Moonraker + self._moonraker_push_lane_data() + + # Request cleanup of old lanes that no longer exist + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("moonraker_cleanup_lane_data", num_gates=self.num_gates) + except Exception as e: + self.log_debug("Failed to cleanup old lane data: %s" % str(e)) + +### SPOOLMAN INTEGRATION ######################################################### + + def _spoolman_sync(self, quiet=True): + if self.spoolman_support == self.SPOOLMAN_PULL: # Remote gate map + # This will pull gate assignment and filament attributes from spoolman db thus replacing the local map + self._spoolman_pull_gate_map(quiet=quiet) + elif self.spoolman_support == self.SPOOLMAN_PUSH: # Local gate map + # This will update spoolman with just the gate assignment (for visualization) and will update + # local gate map attributes with data from spoolman thus overwriting the local map + self._spoolman_push_gate_map(quiet=quiet) + elif self.spoolman_support == self.SPOOLMAN_READONLY: # Get filament attributes only + self._spoolman_update_filaments(quiet=quiet) + + def _spoolman_activate_spool(self, spool_id=-1): + if self.spoolman_support == self.SPOOLMAN_OFF: return + try: + webhooks = self.printer.lookup_object('webhooks') + if spool_id < 0: + self.log_debug("Spoolman spool_id not set for current gate") + else: + if spool_id == 0: + self.log_debug("Deactivating spool...") + spool_id = None # Moonraker API changed and id=0 no longer deactivates + else: + self.log_debug("Activating spool %s..." % spool_id) + webhooks.call_remote_method("spoolman_set_active_spool", spool_id=spool_id) + except Exception as e: + self.log_error("Error while setting active spool: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Request to send filament data from spoolman db (via moonraker) + # gate=None means all gates with spool_id, else specific gate + def _spoolman_update_filaments(self, gate_ids=None, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + if gate_ids is None: # All gates + pruned_gate_ids = [(g, self.gate_spool_id[g]) for g in range(self.num_gates) if self.gate_spool_id[g] >= 0] + else: + pruned_gate_ids = [(g, sid) for g, sid in gate_ids if sid >= 0] + if len(pruned_gate_ids) > 0: + self.log_debug("Requesting the following gate/spool_id pairs from Spoolman: %s" % pruned_gate_ids) + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_get_filaments", gate_ids=pruned_gate_ids, silent=quiet) + except Exception as e: + self.log_error("Error while fetching filament attributes from spoolman: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Store the current gate to spool_id mapping in spoolman db (via moonraker) + def _spoolman_push_gate_map(self, gate_ids=None, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Pushing gate mapping to Spoolman") + if gate_ids is None: # All gates + gate_ids = [(i, self.gate_spool_id[i]) for i in range(self.num_gates)] + try: + webhooks = self.printer.lookup_object('webhooks') + self.log_debug("Storing gate map in spoolman db...") + webhooks.call_remote_method("spoolman_push_gate_map", gate_ids=gate_ids, silent=quiet) + except Exception as e: + self.log_error("Error while pushing gate map to spoolman: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Request to update local gate based on the remote data stored in spoolman db + def _spoolman_pull_gate_map(self, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Requesting the gate map from Spoolman") + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_pull_gate_map", silent=quiet) + except Exception as e: + self.log_error("Error while requesting gate map from spoolman: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Clear the spool to gate association in spoolman db + def _spoolman_clear_gate_map(self, sync=False, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Requesting to clear the gate map in Spoolman") + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_clear_spools_for_printer", sync=sync, silent=quiet) + except Exception as e: + self.log_error("Error while clearing spoolman gate mapping: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Refresh the spoolman cache to pick up changes from elsewhere + def _spoolman_refresh(self, fix, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Requesting to refresh the spoolman gate cache") + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_refresh", fix=fix, silent=quiet) + except Exception as e: + self.log_error("Error while refreshing spoolman gate cache: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Force spool to map association in spoolman db + def _spoolman_set_spool_gate(self, spool_id, gate, sync=False, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Setting spool %d to gate %d directly in spoolman db" % (spool_id, gate)) + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_set_spool_gate", spool_id=spool_id, gate=gate, sync=sync, silent=quiet) + except Exception as e: + self.log_error("Error while setting spoolman gate association: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + def _spoolman_unset_spool_gate(self, spool_id=None, gate=None, sync=False, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Unsetting spool %s or gate %s in spoolman db" % (spool_id, gate)) + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_unset_spool_gate", spool_id=spool_id, gate=gate, sync=sync, silent=quiet) + except Exception as e: + self.log_error("Error while unsetting spoolman gate association: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Dump spool info to console + def _spoolman_display_spool_info(self, spool_id): + if self.spoolman_support == self.SPOOLMAN_OFF: return + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_get_spool_info", spool_id=spool_id) + except Exception as e: + self.log_error("Error while displaying spool info: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Dump spool info to console + def _spoolman_display_spool_location(self, printer=None): + if self.spoolman_support == self.SPOOLMAN_OFF: return + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_display_spool_location", printer=printer) + except Exception as e: + self.log_error("Error while displaying spool location map: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + +### SPOOLMAN COMMANDS ############################################################ + + cmd_MMU_SPOOLMAN_help = "Manage spoolman integration" + def cmd_MMU_SPOOLMAN(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_spoolman_enabled(): return + + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + sync = bool(gcmd.get_int('SYNC', 0, minval=0, maxval=1)) + clear = bool(gcmd.get_int('CLEAR', 0, minval=0, maxval=1)) + refresh = bool(gcmd.get_int('REFRESH', 0, minval=0, maxval=1)) + fix = bool(gcmd.get_int('FIX', 0, minval=0, maxval=1)) + spool_id = gcmd.get_int('SPOOLID', None, minval=1) + gate = gcmd.get_int('GATE', None, minval=-1, maxval=self.num_gates - 1) + printer = gcmd.get('PRINTER', None) # Option to see other printers (only for gate association table atm) + spoolinfo = gcmd.get_int('SPOOLINFO', None, minval=-1) # -1 or 0 is active spool + run = False + + if refresh: + # Rebuild cache in moonraker and sync local and remote + self._spoolman_refresh(fix, quiet=quiet) + if not sync: + self._spoolman_sync(quiet=quiet) + run = True + + if clear: + # Clear the gate allocation in spoolman db + self._spoolman_clear_gate_map(sync=self.spoolman_support == self.SPOOLMAN_PULL, quiet=quiet) + run = True + + if sync: + # Sync local and remote gate maps + self._spoolman_sync(quiet=quiet) + run = True + + # Rest of the options are mutually exclusive + if spoolinfo is not None: + # Dump spool info for active spool or specifed spool id + self._spoolman_display_spool_info(spoolinfo if spoolinfo > 0 else None) + + elif spool_id is not None or gate is not None: + # Update a record in spoolman db + if spool_id is not None and gate is not None: + self._spoolman_set_spool_gate(spool_id, gate, sync=self.spoolman_support == self.SPOOLMAN_PULL, quiet=quiet) + elif spool_id is None and gate is not None: + self._spoolman_unset_spool_gate(gate=gate, sync=self.spoolman_support == self.SPOOLMAN_PULL, quiet=quiet) + elif spool_id is not None and gate is None: + self._spoolman_unset_spool_gate(spool_id=spool_id, sync=self.spoolman_support == self.SPOOLMAN_PULL, quiet=quiet) + + elif not run: + if self.spoolman_support in [self.SPOOLMAN_PULL, self.SPOOLMAN_PUSH]: + # Display gate association table from spoolman db for specified printer + self._spoolman_display_spool_location(printer=printer) + else: + self.log_error("Spoolman gate map not available. Spoolman mode is: %s" % self.spoolman_support) + + +### CORE GCODE COMMANDS ########################################################## + + cmd_MMU_HOME_help = "Home the MMU selector" + def cmd_MMU_HOME(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self._fix_started_state() + + if self.check_if_not_calibrated(self.CALIBRATED_SELECTOR): + self.log_always("Not calibrated. Will home to endstop only!") + tool = -1 + force_unload = 0 + else: + tool = gcmd.get_int('TOOL', 0, minval=0, maxval=self.num_gates - 1) + force_unload = gcmd.get_int('FORCE_UNLOAD', None, minval=0, maxval=1) + + try: + with self.wrap_sync_gear_to_extruder(): + self.home(tool, force_unload=force_unload) + if tool == -1: + self.log_always("Homed") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_SELECT_help = "Select the specified logical tool (following TTG map) or physical gate" + def cmd_MMU_SELECT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_SELECTOR): return + self._fix_started_state() + + bypass = gcmd.get_int('BYPASS', -1, minval=0, maxval=1) + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates - 1) + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + if tool == -1 and gate == -1 and bypass == -1: + raise gcmd.error("Error on 'MMU_SELECT': missing TOOL, GATE or BYPASS") + + try: + with self.wrap_sync_gear_to_extruder(): + self._select(bypass, tool, gate) + msg = self._mmu_visual_to_string() + msg += "\n%s" % self._state_to_string() + self.log_info(msg, color=True) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_SELECT_BYPASS_help = "Select the filament bypass" + def cmd_MMU_SELECT_BYPASS(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_SELECTOR): return + self._fix_started_state() + + try: + with self.wrap_sync_gear_to_extruder(): + self._select(1, -1, -1) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + def _select(self, bypass, tool, gate): + if bypass != -1: + self.select_bypass() + elif tool != -1: + self.select_tool(tool) + else: + self.select_gate(gate) + self._ensure_ttg_match() + + cmd_MMU_CHANGE_TOOL_help = "Perform a tool swap (called from Tx command)" + def cmd_MMU_CHANGE_TOOL(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[]): return # TODO Hard to tell what gates to check so don't check for now + self._fix_started_state() + + quiet = gcmd.get_int('QUIET', 0, minval=0, maxval=1) + standalone = bool(gcmd.get_int('STANDALONE', 0, minval=0, maxval=1)) + restore = bool(gcmd.get_int('RESTORE', 1, minval=0, maxval=1)) + skip_tip = bool(gcmd.get_int('SKIP_TIP', 0, minval=0, maxval=1)) + skip_purge = bool(gcmd.get_int('SKIP_PURGE', 0, minval=0, maxval=1)) + + # Handle "next_pos" option for toolhead position restoration + next_pos = None + sequence_vars_macro = self.printer.lookup_object("gcode_macro _MMU_SEQUENCE_VARS", None) + if sequence_vars_macro and sequence_vars_macro.variables.get('restore_xy_pos', 'last') == 'next': + # Convert next position to absolute coordinates + next_pos = gcmd.get('NEXT_POS', None) + if next_pos: + try: + x, y = map(float, next_pos.split(',')) + gcode_status = self.gcode_move.get_status(self.reactor.monotonic()) + if not gcode_status['absolute_coordinates']: + gcode_pos = gcode_status['gcode_position'] + x += gcode_pos[0] + y += gcode_pos[1] + next_pos = [x, y] + except (ValueError, KeyError, TypeError) as ee: + # If something goes wrong it is better to ignore next pos completely + self.log_error("Error parsing NEXT_POS: %s" % str(ee)) + + # To support Tx commands linked directly (currently not used because of Mainsail visibility which requires macros) + cmd = gcmd.get_command().strip() + match = re.match(r'[Tt](\d{1,3})$', cmd) + if match: + tool = int(match.group(1)) + if tool < 0 or tool > self.num_gates - 1: + raise gcmd.error("Invalid tool") + else: + # Special case for UI driven change tool where gate is chosen + tool = None + gate = gcmd.get_int('GATE', None, minval=0, maxval=self.num_gates - 1) + if gate is not None: + if gate == self.gate_selected: + self.log_always("Gate %s is already loaded as %s" % (gate, self.selected_tool_string(tool))) + return + + possible_tools = [tool for tool in range(self.num_gates) if self.ttg_map[tool] == gate] + if not possible_tools: + self.log_error("No tool associated with gate %s. Check tool-to-gate mapping with MMU_TTG_MAP" % gate) + return + + if self.tool_selected in possible_tools: + self._remap_tool(self.tool_selected, gate) + tool = self.tool_selected + else: + tool = possible_tools[0] + + if tool is None: + tool = gcmd.get_int('TOOL', minval=0, maxval=self.num_gates - 1) + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during tool change + with self._wrap_suspendwrite_variables(): # Reduce I/O activity to a minimum + self._auto_home(tool=tool) + if self.has_encoder(): + self.encoder_sensor.note_clog_detection_length() + + do_form_tip = self.FORM_TIP_STANDALONE + if skip_tip: + do_form_tip = self.FORM_TIP_NONE + elif self.is_printing() and not (standalone or self.force_form_tip_standalone): + do_form_tip = self.FORM_TIP_SLICER + + do_purge = self.PURGE_STANDALONE + if skip_purge: + do_purge = self.PURGE_NONE + elif self.is_printing() and not (standalone or self.force_purge_standalone): + do_purge = self.PURGE_SLICER + + tip_msg = ("with slicer tip forming" if do_form_tip == self.FORM_TIP_SLICER else + "with standalone MMU tip forming" if do_form_tip == self.FORM_TIP_STANDALONE else + "without tip forming") + purge_msg = ("slicer purging" if do_purge == self.PURGE_SLICER else + "standalone MMU purging" if do_purge == self.PURGE_STANDALONE else + "without purging") + self.log_debug("Tool change initiated %s and %s" % (tip_msg, purge_msg)) + + current_tool_string = self.selected_tool_string() + new_tool_string = self.selected_tool_string(tool) + + # Check if we are already loaded + if ( + tool == self.tool_selected and + self.ttg_map[tool] == self.gate_selected and + self.filament_pos == self.FILAMENT_POS_LOADED + ): + self.log_always("Tool %s is already loaded" % self.selected_tool_string(tool)) + return + + # Load only case + if self.filament_pos == self.FILAMENT_POS_UNLOADED: + msg = "Tool change requested: %s" % new_tool_string + m117_msg = "> %s" % new_tool_string + elif self.tool_selected == tool: + msg = "Reloading: %s" % new_tool_string + m117_msg = "> %s" % new_tool_string + else: + # Normal toolchange case + msg = "Tool change requested, from %s to %s" % (current_tool_string, new_tool_string) + m117_msg = "%s > %s" % (current_tool_string, new_tool_string) + + self._note_toolchange(m117_msg) + self.log_always(msg) + + # Check if new tool is mapped to current gate + if self.ttg_map[tool] == self.gate_selected and self.filament_pos == self.FILAMENT_POS_LOADED: + self.select_tool(tool) + self._note_toolchange(self.selected_tool_string(tool)) + return + + # Ok, now ready to park and perform the swap + self._next_tool = tool # Valid only during the change process - cleared in _continue_after() + self.last_statistics = {} + self._save_toolhead_position_and_park('toolchange', next_pos=next_pos) + self._set_next_position(next_pos) # This can also clear next_position + self._track_time_start('total') + self.printer.send_event("mmu:toolchange", self._last_tool, self._next_tool) + + # Remember the tool that was actually in use before any load attempts + prev_tool = self.tool_selected + + attempts = 2 if self.retry_tool_change_on_error and (self.is_printing() or standalone) else 1 # TODO Replace with inattention timer + try: + for i in range(attempts): + try: + if self.filament_pos != self.FILAMENT_POS_UNLOADED: + self._unload_tool(form_tip=do_form_tip, prev_tool=prev_tool) + self._select_and_load_tool(tool, purge=do_purge) + break + except MmuError as ee: + if i == attempts - 1: + raise MmuError("%s.\nOccured when changing tool: %s" % (str(ee), self._last_toolchange)) + self.log_error("%s.\nOccured when changing tool: %s. Retrying..." % (str(ee), self._last_toolchange)) + # Try again but recover_filament_pos will ensure conservative treatment of unload + self.recover_filament_pos() + + self._track_swap_completed() + if self.log_m117_messages: + self.gcode.run_script_from_command("M117 T%s" % tool) + finally: + self._track_time_end('total') + + # Updates swap statistics + self.num_toolchanges += 1 + self._dump_statistics(job=not quiet, gate=not quiet) + self._persist_swap_statistics() + self._persist_gate_statistics() + + # Deliberately outside of _wrap_gear_synced_to_extruder() so there is no absolutely no delay after restoring position + self._continue_after('toolchange', restore=restore) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + + cmd_MMU_LOAD_help = "Loads filament on current tool/gate or optionally loads just the extruder for bypass or recovery usage (EXTRUDER_ONLY=1)" + def cmd_MMU_LOAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[self.gate_selected]): return + self._fix_started_state() + + in_bypass = self.gate_selected == self.TOOL_GATE_BYPASS + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0, minval=0, maxval=1) or in_bypass) + skip_purge = bool(gcmd.get_int('SKIP_PURGE', 0, minval=0, maxval=1)) + restore = bool(gcmd.get_int('RESTORE', 1, minval=0, maxval=1)) + do_purge = self.PURGE_STANDALONE if not skip_purge else self.PURGE_NONE + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during filament load + if self.filament_pos != self.FILAMENT_POS_UNLOADED: + self.log_always("Filament already loaded") + return + + self._note_toolchange("> %s" % self.selected_tool_string()) + + if extruder_only: + self.load_sequence(bowden_move=0., extruder_only=True, purge=do_purge) + + else: + self._next_tool = self.tool_selected # Valid only during the load process - cleared in _continue_after() + self.last_statistics = {} + self._save_toolhead_position_and_park('load') + if self.tool_selected == self.TOOL_GATE_UNKNOWN: + self.log_error("Selected gate is not mapped to any tool. Will load filament but be sure to use MMU_TTG_MAP to assign tool") + + self._select_and_load_tool(self.tool_selected, purge=do_purge) + self._persist_gate_statistics() + self._continue_after('load', restore=restore) + + self._persist_swap_statistics() + + except MmuError as ee: + self.handle_mmu_error("%s.\nOccured when loading tool: %s" % (str(ee), self._last_toolchange)) + if self.tool_selected == self.TOOL_GATE_BYPASS: + self._set_filament_pos_state(self.FILAMENT_POS_UNKNOWN) + + + cmd_MMU_UNLOAD_help = "Unloads filament and parks it at the gate or optionally unloads just the extruder (EXTRUDER_ONLY=1)" + def cmd_MMU_UNLOAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[self.gate_selected]): return + self._fix_started_state() + + if self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_always("Filament not loaded") + return + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during filament unload + self._mmu_unload_eject(gcmd) + + self._persist_swap_statistics() + + except MmuError as ee: + self.handle_mmu_error("%s.\nOccured when unloading tool" % str(ee)) + + cmd_MMU_EJECT_help = "Alias for MMU_UNLOAD if filament is loaded but will fully eject filament from MMU (release from gear) if already in unloaded state" + def cmd_MMU_EJECT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + gate = gcmd.get_int('GATE', self.gate_selected, minval=0, maxval=self.num_gates - 1) + force = bool(gcmd.get_int('FORCE', 0, minval=0, maxval=1)) + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[gate]): return + self._fix_started_state() + + can_crossload = (self.mmu_machine.can_crossload or self.mmu_machine.multigear) and self.sensor_manager.has_gate_sensor(self.SENSOR_GEAR_PREFIX, gate) + if not can_crossload and gate != self.gate_selected: + if self.check_if_loaded(): return + + # Determine if full eject_from_gate is necessary + in_bypass = self.gate_selected == self.TOOL_GATE_BYPASS + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0, minval=0, maxval=1)) + can_eject_from_gate = ( + not extruder_only + and not (in_bypass and self.filament_pos != self.FILAMENT_POS_UNLOADED and gate >= 0) + and ( + (self.mmu_machine.multigear and gate != self.gate_selected) + or self.filament_pos == self.FILAMENT_POS_UNLOADED + or force + ) + ) + + if not can_eject_from_gate and self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_always("Filament not loaded") + return + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during filament eject + + current_gate = self.gate_selected + if gate != current_gate: + self.select_gate(gate) + + try: + self._mmu_unload_eject(gcmd) + if can_eject_from_gate: + self.log_always("Ejecting filament out of %s" % ("current gate" if gate == current_gate else "gate %d" % gate)) + self._eject_from_gate() + + finally: + if self.gate_selected != current_gate: + # If necessary or easy restore previous gate + if self.is_in_print() or self.mmu_machine.multigear or self.filament_pos != self.FILAMENT_POS_UNLOADED: + self.select_gate(current_gate) + else: + # Lazy movement means we have side effect of changed tool/gate + self._ensure_ttg_match() + self._initialize_encoder() # Encoder 0000 + + self._persist_swap_statistics() + + except MmuError as ee: + self.handle_mmu_error("Filament eject for gate %d failed: %s" % (gate, str(ee))) + + # Common logic for MMU_UNLOAD and MMU_EJECT + def _mmu_unload_eject(self, gcmd): + in_bypass = self.gate_selected == self.TOOL_GATE_BYPASS + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0, minval=0, maxval=1)) or in_bypass + skip_tip = bool(gcmd.get_int('SKIP_TIP', 0, minval=0, maxval=1)) + restore = bool(gcmd.get_int('RESTORE', 1, minval=0, maxval=1)) + do_form_tip = self.FORM_TIP_STANDALONE if not skip_tip else self.FORM_TIP_NONE + + self._note_toolchange("< %s" % self.selected_tool_string()) + + if extruder_only: + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER, silent=True) # Ensure tool tip is performed + self.unload_sequence(bowden_move=0., form_tip=do_form_tip, extruder_only=True) + if in_bypass: + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + self.log_always("Please pull the filament out from the MMU") + else: + if self.filament_pos != self.FILAMENT_POS_UNLOADED: + self.last_statistics = {} + self._save_toolhead_position_and_park('unload') + self._unload_tool(form_tip=do_form_tip) + self._persist_gate_statistics() + self._continue_after('unload', restore=restore) + + # Bookend for start of MMU based print + cmd_MMU_PRINT_START_help = "Forces initialization of MMU state ready for print (usually automatic)" + def cmd_MMU_PRINT_START(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_in_print(): + self._on_print_start() + self._clear_macro_state(reset=True) + + # Bookend for end of MMU based print + cmd_MMU_PRINT_END_help = "Forces clean up of state after after print end" + def cmd_MMU_PRINT_END(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + idle_timeout = gcmd.get_int('IDLE_TIMEOUT', 0, minval=0, maxval=1) + end_state = gcmd.get('STATE', "complete") + if not self.is_in_endstate(): + if end_state in ["complete", "error", "cancelled", "ready", "standby"]: + if not idle_timeout and end_state in ["complete"]: + self._save_toolhead_position_and_park("complete") + self._on_print_end(end_state) + else: + raise gcmd.error("Unknown endstate '%s'" % end_state) + + cmd_MMU_LOG_help = "Logs messages in MMU log" + def cmd_MMU_LOG(self, gcmd): + msg = gcmd.get('MSG', "").replace("\\n", "\n").replace(" ", UI_SPACE) + if gcmd.get_int('ERROR', 0, minval=0, maxval=1): + self.log_error(msg) + elif gcmd.get_int('DEBUG', 0, minval=0, maxval=1): + self.log_debug(msg) + else: + self.log_info(msg) + + cmd_MMU_PAUSE_help = "Pause the current print and lock the MMU operations" + def cmd_MMU_PAUSE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + force_in_print = bool(gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1)) # Mimick in-print + msg = gcmd.get('MSG',"MMU_PAUSE macro was directly called") + self.handle_mmu_error(msg, force_in_print) + + cmd_MMU_UNLOCK_help = "Wakeup the MMU prior to resume to restore temperatures and timeouts" + def cmd_MMU_UNLOCK(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self._clear_mmu_error_dialog() + if self.is_mmu_paused_and_locked(): + self._mmu_unlock() + + # Not a user facing command - used in automatic wrapper + cmd_MMU_RESUME_help = "Wrapper around default user RESUME macro" + def cmd_MMU_RESUME(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: + # User defined or Klipper default behavior + self.wrap_gcode_command(" ".join(("__RESUME", gcmd.get_raw_command_parameters())), None) + return + + self.log_debug("MMU RESUME wrapper called") + if not self.is_paused(): + self.log_always("Print is not paused. Resume ignored.") + return + + force_in_print = bool(gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1)) # Mimick in-print + try: + self._clear_mmu_error_dialog() + if self.is_mmu_paused_and_locked(): + self._mmu_unlock() + + # Decide if we are ready to resume and give user opportunity to fix state first + if self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) is True: + self._set_filament_pos_state(self.FILAMENT_POS_LOADED, silent=True) + self.log_always("Automatically set filament state to LOADED based on toolhead sensor") + if self.filament_pos not in [self.FILAMENT_POS_UNLOADED, self.FILAMENT_POS_LOADED]: + raise MmuError("Cannot resume because filament position not indicated as fully loaded (or unloaded). Ensure filament is loaded/unloaded and run:\n MMU_RECOVER LOADED=1 or MMU_RECOVER LOADED=0 or just MMU_RECOVER\nto reset state, then RESUME again") + + # Prevent BASE_RESUME from moving toolhead + if self.TOOLHEAD_POSITION_STATE in self.gcode_move.saved_states: + gcode_pos = self.gcode_move.get_status(self.reactor.monotonic())['gcode_position'] + try: + self.gcode_move.saved_states['PAUSE_STATE']['last_position'][:3] = gcode_pos[:3] + except KeyError: + self.log_error("PAUSE_STATE not defined!") + + self.wrap_gcode_command(" ".join(("__RESUME", gcmd.get_raw_command_parameters())), exception=None) + self._continue_after("resume", force_in_print=force_in_print) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + # Not a user facing command - used in automatic wrapper + cmd_PAUSE_help = "Wrapper around default PAUSE macro" + def cmd_PAUSE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.is_enabled: + self._fix_started_state() # Get out of 'started' state + self.log_debug("MMU PAUSE wrapper called") + self._save_toolhead_position_and_park("pause") + self.wrap_gcode_command(" ".join(("__PAUSE", gcmd.get_raw_command_parameters())), exception=None) + + # Not a user facing command - used in automatic wrapper + cmd_CLEAR_PAUSE_help = "Wrapper around default CLEAR_PAUSE macro" + def cmd_CLEAR_PAUSE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.is_enabled: + self.log_debug("MMU CLEAR_PAUSE wrapper called") + self._clear_macro_state() + if self.saved_toolhead_operation == 'pause': + self._clear_saved_toolhead_position() + self.wrap_gcode_command("__CLEAR_PAUSE", exception=None) + + # Not a user facing command - used in automatic wrapper + cmd_MMU_CANCEL_PRINT_help = "Wrapper around default CANCEL_PRINT macro" + def cmd_MMU_CANCEL_PRINT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.is_enabled: + self._fix_started_state() # Get out of 'started' state before transistion to cancelled + self.log_debug("MMU_CANCEL_PRINT wrapper called") + self._clear_mmu_error_dialog() + self._save_toolhead_position_and_park("cancel") + self.wrap_gcode_command("__CANCEL_PRINT", exception=None) + self._on_print_end("cancelled") + else: + self.wrap_gcode_command("__CANCEL_PRINT", exception=None) + + cmd_MMU_RECOVER_help = "Recover the filament location or manually fix MMU state after intervention/error" + cmd_MMU_RECOVER_param_help = ( + "MMU_RECOVER: %s\n" % cmd_MMU_RECOVER_help + + "TOOL = t Optionally force the assignment of specified tool number\n" + + "GATE = g Optionally force the assignment of the specified gate number (fixes TTG map)\n" + + "BYPASS = 1 Used to force the assignemnt of the bypass Tool/Gate\n" + + "LOADED = [0|1] Force unloaded or loaded (in the extruder) state\n" + + "STRICT = 1 If auto-recovering state, allows extended tests including extruder heating\n" + + "(no parameters for automatic filament position recovery)" + ) + cmd_MMU_RECOVER_supplement_help = ( + "Examples:\n" + + "MMU_RECOVER ...automatically recover filament position\n" + + "MMU_RECOVER LOADED=1 ...to indicate filament is in the extruder\n" + + "MMU_RECOVER TOOL=2 GATE=3 ...to indicate T2 is currently loaded from gate 3" + ) + def cmd_MMU_RECOVER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.log_always(self.format_help(self.cmd_MMU_RECOVER_param_help, self.cmd_MMU_RECOVER_supplement_help), color=True) + return + + tool = gcmd.get_int('TOOL', self.TOOL_GATE_UNKNOWN, minval=-2, maxval=self.num_gates - 1) + mod_gate = gcmd.get_int('GATE', self.TOOL_GATE_UNKNOWN, minval=-2, maxval=self.num_gates - 1) + if gcmd.get_int('BYPASS', None, minval=0, maxval=1): + mod_gate = self.TOOL_GATE_BYPASS + tool = self.TOOL_GATE_BYPASS + loaded = gcmd.get_int('LOADED', -1, minval=0, maxval=1) + strict = gcmd.get_int('STRICT', 0, minval=0, maxval=1) + + try: + if tool == self.TOOL_GATE_BYPASS: + self.selector.restore_gate(self.TOOL_GATE_BYPASS) + self._set_gate_selected(self.TOOL_GATE_BYPASS) + self._set_tool_selected(self.TOOL_GATE_BYPASS) + self._ensure_ttg_match() + + elif tool >= 0: # If tool is specified then use and optionally override the gate + self._set_tool_selected(tool) + gate = self.ttg_map[tool] + if mod_gate >= 0: + gate = mod_gate + if gate >= 0: + self.selector.restore_gate(gate) + self._set_gate_selected(gate) + self.log_info("Remapping T%d to gate %d" % (tool, gate)) + self._remap_tool(tool, gate, loaded) + + elif mod_gate >= 0: # If only gate specified then just reset and ensure tool is correct + self.selector.restore_gate(mod_gate) + self._set_gate_selected(mod_gate) + self._ensure_ttg_match() + + elif tool == self.TOOL_GATE_UNKNOWN and self.tool_selected == self.TOOL_GATE_BYPASS and loaded == -1: + # This is to be able to get out of "stuck in bypass" state + ts = self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) + es = self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY) + if ts or es: # TODO use check_all_sensors() call when sensor_manager is fixed + self._set_filament_pos_state(self.FILAMENT_POS_LOADED, silent=True) + else: + if es is None and ts is None: + self.log_warning("Warning: Making assumption that bypass is unloaded because no toolhead sensors are present") + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=True) + self._set_filament_direction(self.DIRECTION_UNKNOWN) + return + + if loaded == 1: + self._set_filament_direction(self.DIRECTION_LOAD) + self._set_filament_pos_state(self.FILAMENT_POS_LOADED) + elif loaded == 0: + self._set_filament_direction(self.DIRECTION_UNLOAD) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + else: + # Filament position not specified so auto recover + self.recover_filament_pos(strict=strict, message=True) + + # Reset sync state + self.reset_sync_gear_to_extruder(False) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + +### GCODE COMMANDS INTENDED FOR TESTING ########################################## + + cmd_MMU_SOAKTEST_LOAD_SEQUENCE_help = "Soak test tool load/unload sequence" + def cmd_MMU_SOAKTEST_LOAD_SEQUENCE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_not_homed(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL): return + loops = gcmd.get_int('LOOP', 2) + rand = gcmd.get_int('RANDOM', 0) + to_nozzle = gcmd.get_int('FULL', 0) + try: + with self.wrap_sync_gear_to_extruder(): + for l in range(loops): + self.log_always("Testing loop %d / %d" % (l, loops)) + for t in range(self.num_gates): + tool = t + if rand == 1: + tool = random.randint(0, self.num_gates - 1) + gate = self.ttg_map[tool] + if self.gate_status[gate] == self.GATE_EMPTY: + self.log_always("Skipping tool %d of %d because gate %d is empty" % (tool, self.num_gates, gate)) + else: + self.log_always("Testing tool %d of %d (gate %d)" % (tool, self.num_gates, gate)) + if not to_nozzle: + self.select_tool(tool) + self.load_sequence(bowden_move=100., skip_extruder=True) + self.unload_sequence(bowden_move=100.) + else: + self._select_and_load_tool(tool, purge=self.PURGE_NONE) + self._unload_tool() + self.select_tool(0) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_TEST_GRIP_help = "Test the MMU grip for a Tool" + def cmd_MMU_TEST_GRIP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + self.selector.filament_drive() + self.motors_onoff(on=False, motor="gear") + + cmd_MMU_TEST_TRACKING_help = "Test the tracking of gear feed and encoder sensing" + def cmd_MMU_TEST_TRACKING(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self._check_has_encoder(): return + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_not_homed(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[self.gate_selected]): return + direction = gcmd.get_int('DIRECTION', 1, minval=-1, maxval=1) + step = gcmd.get_float('STEP', 1, minval=0.5, maxval=20) + sensitivity = gcmd.get_float('SENSITIVITY', self.encoder_resolution, minval=0.1, maxval=10) + if direction == 0: return + try: + with self.wrap_sync_gear_to_extruder(): + if self.filament_pos not in [self.FILAMENT_POS_START_BOWDEN, self.FILAMENT_POS_IN_BOWDEN]: + # Ready MMU for test if not already setup + self._unload_tool() + self.load_sequence(bowden_move=100. if direction == self.DIRECTION_LOAD else 200., skip_extruder=True) + self.selector.filament_drive() + with self._require_encoder(): + self._initialize_filament_position() + for i in range(1, int(100 / step)): + self.trace_filament_move(None, direction * step, encoder_dwell=None) + measured = self.get_encoder_distance() + moved = i * step + drift = int(round((moved - measured) / sensitivity)) + if drift > 0: + drift_str = "++++++++!!"[0:drift] + elif (moved - measured) < 0: + drift_str = "--------!!"[0:-drift] + else: + drift_str = "" + self.log_info("Gear/Encoder : %05.2f / %05.2f mm %s" % (moved, measured, drift_str)) + self._unload_tool() + except MmuError as ee: + self.handle_mmu_error("Tracking test failed: %s" % str(ee)) + + cmd_MMU_TEST_LOAD_help = "For quick testing filament loading from gate to the extruder" + def cmd_MMU_TEST_LOAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[self.gate_selected]): return + full = gcmd.get_int('FULL', 0, minval=0, maxval=1) + try: + with self.wrap_sync_gear_to_extruder(): + if full: + self.load_sequence(skip_extruder=True) + else: + length = gcmd.get_float('LENGTH', 100., minval=10., maxval=self.calibration_manager.get_bowden_length()) + self.load_sequence(bowden_move=length, skip_extruder=True) + except MmuError as ee: + self.handle_mmu_error("Load test failed: %s" % str(ee)) + + cmd_MMU_TEST_MOVE_help = "Test filament move to help debug setup / options" + def cmd_MMU_TEST_MOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + debug = bool(gcmd.get_int('DEBUG', 0, minval=0, maxval=1)) # Hidden option + allow_bypass = bool(gcmd.get_int('ALLOW_BYPASS', 0, minval=0, maxval=1)) + + with self.wrap_sync_gear_to_extruder(): + with DebugStepperMovement(self, debug): + actual,_,measured,_ = self._move_cmd(gcmd, "Test move", allow_bypass=allow_bypass) + self.log_always("Moved %.1fmm%s" % (actual, (" (measured %.1fmm)" % measured) if self._can_use_encoder() else "")) + + cmd_MMU_TEST_HOMING_MOVE_help = "Test filament homing move to help debug setup / options" + def cmd_MMU_TEST_HOMING_MOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + allow_bypass = bool(gcmd.get_int('ALLOW_BYPASS', 0, minval=0, maxval=1)) + + with self.wrap_sync_gear_to_extruder(): + debug = bool(gcmd.get_int('DEBUG', 0, minval=0, maxval=1)) # Hidden option + with DebugStepperMovement(self, debug): + actual,homed,measured,_ = self._homing_move_cmd(gcmd, "Test homing move", allow_bypass=allow_bypass) + self.log_always("%s after %.1fmm%s" % (("Homed" if homed else "Did not home"), actual, (" (measured %.1fmm)" % measured) if self._can_use_encoder() else "")) + + cmd_MMU_TEST_CONFIG_help = "Runtime adjustment of MMU configuration for testing or in-print tweaking purposes" + def cmd_MMU_TEST_CONFIG(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + + # Try to catch illegal parameters + illegal_params = [ + p for p in gcmd.get_command_parameters() + if vars(self).get(p.lower()) is None + and self.selector.check_test_config(p.lower()) + and self.sync_feedback_manager.check_test_config(p.lower()) + and self.environment_manager.check_test_config(p.lower()) + and p.lower() not in [ + self.VARS_MMU_CALIB_BOWDEN_LENGTH, + self.VARS_MMU_CALIB_CLOG_LENGTH + ] + and p.upper() not in ['QUIET'] + ] + if illegal_params: + raise gcmd.error("Unknown parameter: %s" % illegal_params) + + # Filament Speeds + self.gear_from_buffer_speed = gcmd.get_float('GEAR_FROM_BUFFER_SPEED', self.gear_from_buffer_speed, minval=10.) + self.gear_from_buffer_accel = gcmd.get_float('GEAR_FROM_BUFFER_ACCEL', self.gear_from_buffer_accel, minval=10.) + self.gear_from_spool_speed = gcmd.get_float('GEAR_FROM_SPOOL_SPEED', self.gear_from_spool_speed, minval=10.) + self.gear_from_spool_accel = gcmd.get_float('GEAR_FROM_SPOOL_ACCEL', self.gear_from_spool_accel, above=10.) + self.gear_unload_speed = gcmd.get_float('GEAR_UNLOAD_SPEED', self.gear_unload_speed, minval=10.) + self.gear_unload_accel = gcmd.get_float('GEAR_UNLOAD_ACCEL', self.gear_unload_accel, above=10.) + self.gear_short_move_speed = gcmd.get_float('GEAR_SHORT_MOVE_SPEED', self.gear_short_move_speed, minval=10.) + self.gear_short_move_accel = gcmd.get_float('GEAR_SHORT_MOVE_ACCEL', self.gear_short_move_accel, minval=10.) + self.gear_short_move_threshold = gcmd.get_float('GEAR_SHORT_MOVE_THRESHOLD', self.gear_short_move_threshold, minval=0.) + self.gear_homing_speed = gcmd.get_float('GEAR_HOMING_SPEED', self.gear_homing_speed, above=1.) + self.extruder_homing_speed = gcmd.get_float('EXTRUDER_HOMING_SPEED', self.extruder_homing_speed, above=1.) + self.extruder_load_speed = gcmd.get_float('EXTRUDER_LOAD_SPEED', self.extruder_load_speed, above=1.) + self.extruder_unload_speed = gcmd.get_float('EXTRUDER_UNLOAD_SPEED', self.extruder_unload_speed, above=1.) + self.extruder_sync_load_speed = gcmd.get_float('EXTRUDER_SYNC_LOAD_SPEED', self.extruder_sync_load_speed, above=1.) + self.extruder_sync_unload_speed = gcmd.get_float('EXTRUDER_SYNC_UNLOAD_SPEED', self.extruder_sync_unload_speed, above=1.) + self.extruder_accel = gcmd.get_float('EXTRUDER_ACCEL', self.extruder_accel, above=10.) + + # Synchronous motor control + self.sync_to_extruder = gcmd.get_int('SYNC_TO_EXTRUDER', self.sync_to_extruder, minval=0, maxval=1) + self.sync_form_tip = gcmd.get_int('SYNC_FORM_TIP', self.sync_form_tip, minval=0, maxval=1) + self.sync_purge = gcmd.get_int('SYNC_PURGE', self.sync_purge, minval=0, maxval=1) + if self.mmu_machine.filament_always_gripped: + self.sync_to_extruder = self.sync_form_tip = self.sync_purge = 1 + + # TMC current control + self.sync_gear_current = gcmd.get_int('SYNC_GEAR_CURRENT', self.sync_gear_current, minval=10, maxval=100) + self.extruder_collision_homing_current = gcmd.get_int('EXTRUDER_COLLISION_HOMING_CURRENT', self.extruder_collision_homing_current, minval=10, maxval=100) + self.extruder_form_tip_current = gcmd.get_int('EXTRUDER_FORM_TIP_CURRENT', self.extruder_form_tip_current, minval=100, maxval=150) + self.extruder_purge_current = gcmd.get_int('EXTRUDER_PURGE_CURRENT', self.extruder_purge_current, minval=100, maxval=150) + + # Homing, loading and unloading controls + gate_homing_endstop = gcmd.get('GATE_HOMING_ENDSTOP', self.gate_homing_endstop) + if gate_homing_endstop not in self.GATE_ENDSTOPS: + raise gcmd.error("gate_homing_endstop is invalid. Options are: %s" % self.GATE_ENDSTOPS) + if gate_homing_endstop != self.gate_homing_endstop: + self.gate_homing_endstop = gate_homing_endstop + self.calibration_manager.adjust_bowden_lengths_on_homing_change() + + # Special bowden calibration (get current length after potential gate_homing_endstop change) + gate_selected = max(self.gate_selected, 0) # Assume gate 0 if not known / bypass + bowden_length = gcmd.get_float('MMU_CALIBRATION_BOWDEN_LENGTH', self.bowden_lengths[gate_selected], minval=0.) + if bowden_length != self.bowden_lengths[gate_selected]: + self.calibration_manager.update_bowden_length(bowden_length, gate_selected) + + self.gate_endstop_to_encoder = gcmd.get_float('GATE_SENSOR_TO_ENCODER', self.gate_endstop_to_encoder) + self.gate_autoload = gcmd.get_int('GATE_AUTOLOAD', self.gate_autoload, minval=0, maxval=1) + self.gate_final_eject_distance = gcmd.get_float('GATE_FINAL_EJECT_DISTANCE', self.gate_final_eject_distance) + self.gate_unload_buffer = gcmd.get_float('GATE_UNLOAD_BUFFER', self.gate_unload_buffer, minval=0.) + self.gate_homing_max = gcmd.get_float('GATE_HOMING_MAX', self.gate_homing_max) + self.gate_parking_distance = gcmd.get_float('GATE_PARKING_DISTANCE', self.gate_parking_distance) + self.gate_preload_homing_max = gcmd.get_float('GATE_PRELOAD_HOMING_MAX', self.gate_preload_homing_max) + self.gate_preload_parking_distance = gcmd.get_float('GATE_PRELOAD_PARKING_DISTANCE', self.gate_preload_parking_distance) + + self.bypass_autoload = gcmd.get_int('BYPASS_AUTOLOAD', self.bypass_autoload, minval=0, maxval=1) + self.bowden_apply_correction = gcmd.get_int('BOWDEN_APPLY_CORRECTION', self.bowden_apply_correction, minval=0, maxval=1) + self.bowden_allowable_unload_delta = self.bowden_allowable_load_delta = gcmd.get_float('BOWDEN_ALLOWABLE_LOAD_DELTA', self.bowden_allowable_load_delta, minval=1., maxval=50.) + self.bowden_pre_unload_test = gcmd.get_int('BOWDEN_PRE_UNLOAD_TEST', self.bowden_pre_unload_test, minval=0, maxval=1) + + extruder_homing_endstop = gcmd.get('EXTRUDER_HOMING_ENDSTOP', self.extruder_homing_endstop) + if extruder_homing_endstop not in self.EXTRUDER_ENDSTOPS: + raise gcmd.error("extruder_homing_endstop is invalid. Options are: %s" % self.EXTRUDER_ENDSTOPS) + self.extruder_homing_endstop = extruder_homing_endstop + + self.extruder_homing_max = gcmd.get_float('EXTRUDER_HOMING_MAX', self.extruder_homing_max, above=10.) + self.extruder_force_homing = gcmd.get_int('EXTRUDER_FORCE_HOMING', self.extruder_force_homing, minval=0, maxval=1) + + self.toolhead_homing_max = gcmd.get_float('TOOLHEAD_HOMING_MAX', self.toolhead_homing_max, minval=0.) + self.toolhead_entry_to_extruder = gcmd.get_float('TOOLHEAD_ENTRY_TO_EXTRUDER', self.toolhead_entry_to_extruder, minval=0.) + self.toolhead_sensor_to_nozzle = gcmd.get_float('TOOLHEAD_SENSOR_TO_NOZZLE', self.toolhead_sensor_to_nozzle, minval=0.) + self.toolhead_extruder_to_nozzle = gcmd.get_float('TOOLHEAD_EXTRUDER_TO_NOZZLE', self.toolhead_extruder_to_nozzle, minval=0.) + self.toolhead_residual_filament = gcmd.get_float('TOOLHEAD_RESIDUAL_FILAMENT', self.toolhead_residual_filament, minval=0.) + self.toolhead_ooze_reduction = gcmd.get_float('TOOLHEAD_OOZE_REDUCTION', self.toolhead_ooze_reduction, minval=-5., maxval=20.) + self.toolhead_unload_safety_margin = gcmd.get_float('TOOLHEAD_UNLOAD_SAFETY_MARGIN', self.toolhead_unload_safety_margin, minval=0.) + self.toolhead_post_load_tighten = gcmd.get_int('TOOLHEAD_POST_LOAD_TIGHTEN', self.toolhead_post_load_tighten, minval=0, maxval=100) + self.toolhead_post_load_tension_adjust = gcmd.get_int('TOOLHEAD_POST_LOAD_TENSION_ADJUST', self.toolhead_post_load_tension_adjust, minval=0, maxval=1) + self.toolhead_entry_tension_test = gcmd.get_int('TOOLHEAD_ENTRY_TENSION_TEST', self.toolhead_entry_tension_test, minval=0, maxval=1) + self.gcode_load_sequence = gcmd.get_int('GCODE_LOAD_SEQUENCE', self.gcode_load_sequence, minval=0, maxval=1) + self.gcode_unload_sequence = gcmd.get_int('GCODE_UNLOAD_SEQUENCE', self.gcode_unload_sequence, minval=0, maxval=1) + + # Software behavior options + self.extruder_temp_variance = gcmd.get_float('EXTRUDER_TEMP_VARIANCE', self.extruder_temp_variance, minval=1.) + self.endless_spool_enabled = gcmd.get_int('ENDLESS_SPOOL_ENABLED', self.endless_spool_enabled, minval=0, maxval=1) + self.endless_spool_on_load = gcmd.get_int('ENDLESS_SPOOL_ON_LOAD', self.endless_spool_on_load, minval=0, maxval=1) + self.endless_spool_eject_gate = gcmd.get_int('ENDLESS_SPOOL_EJECT_GATE', self.endless_spool_eject_gate, minval=-1, maxval=self.num_gates - 1) + + prev_spoolman_support = self.spoolman_support + spoolman_support = gcmd.get('SPOOLMAN_SUPPORT', self.spoolman_support) + if spoolman_support not in self.SPOOLMAN_OPTIONS: + raise gcmd.error("spoolman_support is invalid. Options are: %s" % self.SPOOLMAN_OPTIONS) + if spoolman_support == self.SPOOLMAN_OFF: + self.gate_spool_id[:] = [-1] * self.num_gates + self.spoolman_support = spoolman_support + + prev_t_macro_color = self.t_macro_color + t_macro_color = gcmd.get('T_MACRO_COLOR', self.t_macro_color) + if t_macro_color not in self.T_MACRO_COLOR_OPTIONS: + raise gcmd.error("t_macro_color is invalid. Options are: %s" % self.T_MACRO_COLOR_OPTIONS) + self.t_macro_color = t_macro_color + + self.log_level = gcmd.get_int('LOG_LEVEL', self.log_level, minval=0, maxval=4) + self.log_file_level = gcmd.get_int('LOG_FILE_LEVEL', self.log_file_level, minval=0, maxval=4) + self.log_visual = gcmd.get_int('LOG_VISUAL', self.log_visual, minval=0, maxval=1) + self.log_statistics = gcmd.get_int('LOG_STATISTICS', self.log_statistics, minval=0, maxval=1) + self.log_m117_messages = gcmd.get_int('LOG_M117_MESSAGES', self.log_m117_messages, minval=0, maxval=1) + + console_gate_stat = gcmd.get('CONSOLE_GATE_STAT', self.console_gate_stat) + if console_gate_stat not in self.GATE_STATS_TYPES: + raise gcmd.error("console_gate_stat is invalid. Options are: %s" % self.GATE_STATS_TYPES) + self.console_gate_stat = console_gate_stat + + self.slicer_tip_park_pos = gcmd.get_float('SLICER_TIP_PARK_POS', self.slicer_tip_park_pos, minval=0.) + self.force_form_tip_standalone = gcmd.get_int('FORCE_FORM_TIP_STANDALONE', self.force_form_tip_standalone, minval=0, maxval=1) + self.force_purge_standalone = gcmd.get_int('FORCE_PURGE_STANDALONE', self.force_purge_standalone, minval=0, maxval=1) + self.strict_filament_recovery = gcmd.get_int('STRICT_FILAMENT_RECOVERY', self.strict_filament_recovery, minval=0, maxval=1) + self.filament_recovery_on_pause = gcmd.get_int('FILAMENT_RECOVERY_ON_PAUSE', self.filament_recovery_on_pause, minval=0, maxval=1) + self.preload_attempts = gcmd.get_int('PRELOAD_ATTEMPTS', self.preload_attempts, minval=1, maxval=20) + self.encoder_move_validation = gcmd.get_int('ENCODER_MOVE_VALIDATION', self.encoder_move_validation, minval=0, maxval=1) + self.autotune_rotation_distance = gcmd.get_int('AUTOTUNE_ROTATION_DISTANCE', self.autotune_rotation_distance, minval=0, maxval=1) + self.autotune_bowden_length = gcmd.get_int('AUTOTUNE_BOWDEN_LENGTH', self.autotune_bowden_length, minval=0, maxval=1) + self.retry_tool_change_on_error = gcmd.get_int('RETRY_TOOL_CHANGE_ON_ERROR', self.retry_tool_change_on_error, minval=0, maxval=1) + self.print_start_detection = gcmd.get_int('PRINT_START_DETECTION', self.print_start_detection, minval=0, maxval=1) + self.show_error_dialog = gcmd.get_int('SHOW_ERROR_DIALOG', self.show_error_dialog, minval=0, maxval=1) + form_tip_macro = gcmd.get('FORM_TIP_MACRO', self.form_tip_macro) + if form_tip_macro != self.form_tip_macro: + self.form_tip_vars = None # If macro is changed invalidate defaults + self.form_tip_macro = form_tip_macro + self.purge_macro = gcmd.get('PURGE_MACRO', self.purge_macro) + + # Available only with espooler + if self.has_espooler(): + self.espooler_min_distance = gcmd.get_float('ESPOOLER_MIN_DISTANCE', self.espooler_min_distance, above=0) + self.espooler_max_stepper_speed = gcmd.get_float('ESPOOLER_MAX_STEPPER_SPEED', self.espooler_max_stepper_speed, above=0) + self.espooler_min_stepper_speed = gcmd.get_float('ESPOOLER_MIN_STEPPER_SPEED', self.espooler_min_stepper_speed, minval=0., below=self.espooler_max_stepper_speed) + self.espooler_speed_exponent = gcmd.get_float('ESPOOLER_SPEED_EXPONENT', self.espooler_speed_exponent, above=0) + self.espooler_assist_reduced_speed = gcmd.get_int('ESPOOLER_ASSIST_REDUCED_SPEED', 50, minval=0, maxval=100) + self.espooler_printing_power = gcmd.get_int('ESPOOLER_PRINTING_POWER', self.espooler_printing_power, minval=0, maxval=100) + self.espooler_assist_extruder_move_length = gcmd.get_float("ESPOOLER_ASSIST_EXTRUDER_MOVE_LENGTH", self.espooler_assist_extruder_move_length, above=10.) + self.espooler_assist_burst_power = gcmd.get_int("ESPOOLER_ASSIST_BURST_POWER", self.espooler_assist_burst_power, minval=0, maxval=100) + self.espooler_assist_burst_duration = gcmd.get_float("ESPOOLER_ASSIST_BURST_DURATION", self.espooler_assist_burst_duration, above=0., maxval=10.) + self.espooler_assist_burst_trigger = gcmd.get_int("ESPOOLER_ASSIST_BURST_TRIGGER", self.espooler_assist_burst_trigger, minval=0, maxval=1) + self.espooler_assist_burst_trigger_max = gcmd.get_int("ESPOOLER_ASSIST_BURST_TRIGGER_MAX", self.espooler_assist_burst_trigger_max, minval=1) + self.espooler_rewind_burst_power = gcmd.get_int("ESPOOLER_REWIND_BURST_POWER", self.espooler_rewind_burst_power, minval=0, maxval=100) + self.espooler_rewind_burst_duration = gcmd.get_float("ESPOOLER_REWIND_BURST_DURATION", self.espooler_rewind_burst_duration, above=0., maxval=10.) + + espooler_operations = list(gcmd.get('ESPOOLER_OPERATIONS', ','.join(self.espooler_operations)).split(',')) + for op in espooler_operations: + if op not in self.ESPOOLER_OPERATIONS: + raise gcmd.error("espooler_operations '%s' is invalid. Options are: %s" % (op, self.ESPOOLER_OPERATIONS)) + self.espooler_operations = espooler_operations + + # Available only with encoder + if self.has_encoder(): + cal_clog_length = self.save_variables.allVariables.get(self.VARS_MMU_CALIB_CLOG_LENGTH, None) + clog_length = gcmd.get_float('MMU_CALIBRATION_CLOG_LENGTH', cal_clog_length, minval=1., maxval=100.) + if clog_length != cal_clog_length: + self.calibration_manager.update_clog_detection_length(clog_length, force=True) + + # Currently hidden and testing options + self.test_random_failures = gcmd.get_int('TEST_RANDOM_FAILURES', self.test_random_failures, minval=0, maxval=1) + self.test_disable_encoder = gcmd.get_int('TEST_DISABLE_ENCODER', self.test_disable_encoder, minval=0, maxval=1) + self.test_force_in_print = gcmd.get_int('TEST_FORCE_IN_PRINT', self.test_force_in_print, minval=0, maxval=1) + self.canbus_comms_retries = gcmd.get_int('CANBUS_COMMS_RETRIES', self.canbus_comms_retries, minval=1, maxval=10) + self.serious = gcmd.get_int('SERIOUS', self.serious, minval=0, maxval=1) + + # Sub components + for m in self.managers: + if hasattr(m, 'set_test_config'): + m.set_test_config(gcmd) + + if not quiet: + msg = "FILAMENT MOVEMENT SPEEDS:" + msg += "\ngear_from_spool_speed = %.1f" % self.gear_from_spool_speed + msg += "\ngear_from_spool_accel = %.1f" % self.gear_from_spool_accel + msg += "\ngear_unload_speed = %.1f" % self.gear_unload_speed + msg += "\ngear_unload_accel = %.1f" % self.gear_unload_accel + if self.has_filament_buffer: + msg += "\ngear_from_buffer_speed = %.1f" % self.gear_from_buffer_speed + msg += "\ngear_from_buffer_accel = %.1f" % self.gear_from_buffer_accel + msg += "\ngear_short_move_speed = %.1f" % self.gear_short_move_speed + msg += "\ngear_short_move_accel = %.1f" % self.gear_short_move_accel + msg += "\ngear_short_move_threshold = %.1f" % self.gear_short_move_threshold + msg += "\ngear_homing_speed = %.1f" % self.gear_homing_speed + msg += "\nextruder_homing_speed = %.1f" % self.extruder_homing_speed + msg += "\nextruder_load_speed = %.1f" % self.extruder_load_speed + msg += "\nextruder_unload_speed = %.1f" % self.extruder_unload_speed + msg += "\nextruder_sync_load_speed = %.1f" % self.extruder_sync_load_speed + msg += "\nextruder_sync_unload_speed = %.1f" % self.extruder_sync_unload_speed + msg += "\nextruder_accel = %.1f" % self.extruder_accel + + msg += "\n\nMOTOR SYNC CONTROL:" + msg += "\nsync_to_extruder = %d" % self.sync_to_extruder + msg += "\nsync_form_tip = %d" % self.sync_form_tip + msg += "\nsync_purge = %d" % self.sync_purge + msg += "\nsync_gear_current = %d%%" % self.sync_gear_current + if self.has_encoder(): + msg += "\nextruder_collision_homing_current = %d%%" % self.extruder_collision_homing_current + msg += "\nextruder_form_tip_current = %d%%" % self.extruder_form_tip_current + msg += "\nextruder_purge_current = %d%%" % self.extruder_purge_current + msg += self.sync_feedback_manager.get_test_config() + + msg += "\n\nLOADING/UNLOADING:" + msg += "\ngate_homing_endstop = %s" % self.gate_homing_endstop + if self.gate_homing_endstop in [self.SENSOR_GATE] and self.has_encoder(): + msg += "\ngate_endstop_to_encoder = %s" % self.gate_endstop_to_encoder + msg += "\ngate_unload_buffer = %s" % self.gate_unload_buffer + msg += "\ngate_homing_max = %s" % self.gate_homing_max + msg += "\ngate_parking_distance = %s" % self.gate_parking_distance + msg += "\ngate_preload_homing_max = %s" % self.gate_preload_homing_max + msg += "\ngate_preload_parking_distance = %s" % self.gate_preload_parking_distance + msg += "\ngate_autoload = %s" % self.gate_autoload + msg += "\ngate_final_eject_distance = %s" % self.gate_final_eject_distance + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "\nbypass_autoload = %s" % self.bypass_autoload + if self.has_encoder(): + msg += "\nbowden_apply_correction = %d" % self.bowden_apply_correction + msg += "\nbowden_allowable_load_delta = %d" % self.bowden_allowable_load_delta + msg += "\nbowden_pre_unload_test = %d" % self.bowden_pre_unload_test + msg += "\nextruder_force_homing = %d" % self.extruder_force_homing + msg += "\nextruder_homing_endstop = %s" % self.extruder_homing_endstop + msg += "\nextruder_homing_max = %.1f" % self.extruder_homing_max + msg += "\ntoolhead_extruder_to_nozzle = %.1f" % self.toolhead_extruder_to_nozzle + if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + msg += "\ntoolhead_sensor_to_nozzle = %.1f" % self.toolhead_sensor_to_nozzle + msg += "\ntoolhead_homing_max = %.1f" % self.toolhead_homing_max + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "\ntoolhead_entry_to_extruder = %.1f" % self.toolhead_entry_to_extruder + msg += "\ntoolhead_residual_filament = %.1f" % self.toolhead_residual_filament + msg += "\ntoolhead_ooze_reduction = %.1f" % self.toolhead_ooze_reduction + msg += "\ntoolhead_unload_safety_margin = %d" % self.toolhead_unload_safety_margin + msg += "\ntoolhead_entry_tension_test = %d" % self.toolhead_entry_tension_test + msg += "\ntoolhead_post_load_tighten = %d" % self.toolhead_post_load_tighten + msg += "\ntoolhead_post_load_tension_adjust = %d" % self.toolhead_post_load_tension_adjust + msg += "\ngcode_load_sequence = %d" % self.gcode_load_sequence + msg += "\ngcode_unload_sequence = %d" % self.gcode_unload_sequence + + msg += "\n\nTIP FORMING/CUTTING:" + msg += "\nform_tip_macro = %s" % self.form_tip_macro + msg += "\nforce_form_tip_standalone = %d" % self.force_form_tip_standalone + if not self.force_form_tip_standalone: + msg += "\nslicer_tip_park_pos = %.1f" % self.slicer_tip_park_pos + + msg += "\n\nPURGING:" + msg += "\npurge_macro = %s" % self.purge_macro + msg += "\nforce_purge_standalone = %d" % self.force_purge_standalone + + if self.has_espooler(): + msg += "\n\nESPOOLER:" + msg += "\nespooler_min_distance = %s" % self.espooler_min_distance + msg += "\nespooler_max_stepper_speed = %s" % self.espooler_max_stepper_speed + msg += "\nespooler_min_stepper_speed = %s" % self.espooler_min_stepper_speed + msg += "\nespooler_speed_exponent = %s" % self.espooler_speed_exponent + msg += "\nespooler_assist_reduced_speed = %s%%" % self.espooler_assist_reduced_speed + msg += "\nespooler_printing_power = %s%%" % self.espooler_printing_power + msg += "\nespooler_assist_extruder_move_length = %s" % self.espooler_assist_extruder_move_length + msg += "\nespooler_assist_burst_power = %d" % self.espooler_assist_burst_power + msg += "\nespooler_assist_burst_duration = %s" % self.espooler_assist_burst_duration + msg += "\nespooler_assist_burst_trigger = %d" % self.espooler_assist_burst_trigger + msg += "\nespooler_assist_burst_trigger_max = %d" % self.espooler_assist_burst_trigger_max + msg += "\nespooler_rewind_burst_power = %d" % self.espooler_rewind_burst_power + msg += "\nespooler_rewind_burst_duration = %s" % self.espooler_rewind_burst_duration + msg += "\nespooler_operations = %s" % self.espooler_operations + + # Heater/Environment + msg += self.environment_manager.get_test_config() + + msg += "\n\nLOGGING:" + msg += "\nlog_level = %d" % self.log_level + msg += "\nlog_visual = %d" % self.log_visual + if self.mmu_logger: + msg += "\nlog_file_level = %d" % self.log_file_level + msg += "\nlog_statistics = %d" % self.log_statistics + msg += "\nlog_m117_messages = %d" % self.log_m117_messages + msg += "\nconsole_gate_stat = %s" % self.console_gate_stat + + msg += "\n\nOTHER:" + msg += "\nextruder_temp_variance = %.1f" % self.extruder_temp_variance + msg += "\nendless_spool_enabled = %d" % self.endless_spool_enabled + msg += "\nendless_spool_on_load = %d" % self.endless_spool_on_load + msg += "\nendless_spool_eject_gate = %d" % self.endless_spool_eject_gate + msg += "\nspoolman_support = %s" % self.spoolman_support + msg += "\nt_macro_color = %s" % self.t_macro_color + msg += "\npreload_attempts = %d" % self.preload_attempts + if self.has_encoder(): + msg += "\nstrict_filament_recovery = %d" % self.strict_filament_recovery + msg += "\nencoder_move_validation = %d" % self.encoder_move_validation + msg += "\nautotune_rotation_distance = %d" % self.autotune_rotation_distance + msg += "\nautotune_bowden_length = %d" % self.autotune_bowden_length + msg += "\nfilament_recovery_on_pause = %d" % self.filament_recovery_on_pause + msg += "\nretry_tool_change_on_error = %d" % self.retry_tool_change_on_error + msg += "\nprint_start_detection = %d" % self.print_start_detection + msg += "\nshow_error_dialog = %d" % self.show_error_dialog + + # Selector and Servo + msg += self.selector.get_test_config() + + # These are in mmu_vars.cfg and are offered here for convenience + msg += "\n\nCALIBRATION (mmu_vars.cfg):" + if self.mmu_machine.variable_bowden_lengths: + msg += "\nmmu_calibration_bowden_lengths = %s" % self.bowden_lengths + else: + msg += "\nmmu_calibration_bowden_length = %.1f" % self.bowden_lengths[0] + if self.has_encoder(): + msg += "\nmmu_calibration_clog_length = %.1f" % self.save_variables.allVariables.get(self.VARS_MMU_CALIB_CLOG_LENGTH, -1) + + self.log_info(msg) + + # Some changes need additional action to be taken + if prev_spoolman_support != self.spoolman_support: + self._spoolman_sync() + if prev_t_macro_color != self.t_macro_color: + self._update_t_macros() + + +########################################### +# RUNOUT, ENDLESS SPOOL and GATE HANDLING # +########################################### + + # Handler for all "runout" type events including "clog" and "tangle". + # If event_type is None then caller isn't sure (runout or clog) + def _runout(self, event_type=None, sensor=None): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during handling + self.is_handling_runout = (event_type == "runout") # Best starting assumption + self._save_toolhead_position_and_park('runout') # includes "clog" and "tangle" + + type_str = event_type or "runout/clog/tangle" + if self.tool_selected < 0: + raise MmuError("Filament %s on an unknown or bypass tool\nManual intervention is required" % type_str) + + if self.filament_pos != self.FILAMENT_POS_LOADED and event_type is None: + raise MmuError("Filament %s occured but filament is marked as not loaded(?)\nManual intervention is required" % type_str) + + self.log_debug("Issue on tool T%d" % self.tool_selected) + + # Check for clog/tangle by looking for filament at the gate (or in the encoder) + if event_type is None: + if not self.check_filament_runout(): + if self.has_encoder(): + self.encoder_sensor.note_clog_detection_length() + # Eliminate runout + event_type = "clog/tangle" + self.is_handling_runout = False + raise MmuError("A clog/tangle has been detected and requires manual intervention") + else: + # We definitely have a filament runout + type_str = event_type = "runout" + self.is_handling_runout = True # Will remain true until complete and continue or resume after error + + if event_type == "runout": + if self.endless_spool_enabled: + self._next_tool = self.tool_selected # Valid only during the reload process - cleared in _continue_after() + self._set_gate_status(self.gate_selected, self.GATE_EMPTY) # Indicate current gate is empty + next_gate, msg = self._get_next_endless_spool_gate(self.tool_selected, self.gate_selected) + if next_gate == -1: + raise MmuError("Runout detected on %s\nNo alternative gates available after checking %s" % (sensor, msg)) + + self.log_error("A runout has been detected. Checking for alternative gates %s" % msg) + self.log_info("Remapping T%d to gate %d" % (self.tool_selected, next_gate)) + + if self.endless_spool_eject_gate > 0: + self.log_info("Ejecting filament remains to designated waste gate %d" % self.endless_spool_eject_gate) + self.select_gate(self.endless_spool_eject_gate) + self._unload_tool(form_tip=self.FORM_TIP_STANDALONE) + self._eject_from_gate() # Push completely out of gate + self.select_gate(next_gate) # Necessary if unloaded to waste gate + self._remap_tool(self.tool_selected, next_gate) + self._select_and_load_tool(self.tool_selected, purge=self.PURGE_STANDALONE) # if user has set up standalone purging, respect option and purge. + + self._continue_after("endless_spool") + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + return + else: + raise MmuError("Runout detected on %s\nEndlessSpool mode is off - manual intervention is required" % sensor) + + raise MmuError("A %s has been detected on %s and requires manual intervention" % (type_str, sensor)) + + def _get_next_endless_spool_gate(self, tool, gate): + group = self.endless_spool_groups[gate] + next_gate = -1 + checked_gates = [] + for i in range(self.num_gates - 1): + check = (gate + i + 1) % self.num_gates + if self.endless_spool_groups[check] == group: + checked_gates.append(check) + if self.gate_status[check] != self.GATE_EMPTY: + next_gate = check + break + alt_gates = "(checked gates: %s)" % ",".join(map(str, checked_gates)) + msg = "for T%d in EndlessSpool Group %s %s" % (tool, chr(ord('A') + group), alt_gates) + return next_gate, msg + + # Use pre-gate (and gear) sensors to "correct" gate status + # Return updated gate_status adjusted by sensor readings + def _validate_gate_status(self, gate_status): + v_gate_status = list(gate_status) # Ensure that webhooks sees get_status() change + for gate, status in enumerate(v_gate_status): + gear_detected = self.sensor_manager.check_gate_sensor(self.SENSOR_GEAR_PREFIX, gate) + if gear_detected is True: + v_gate_status[gate] = self.GATE_AVAILABLE + else: + pre_detected = self.sensor_manager.check_gate_sensor(self.SENSOR_PRE_GATE_PREFIX, gate) + if pre_detected is True and status == self.GATE_EMPTY: + v_gate_status[gate] = self.GATE_UNKNOWN + elif pre_detected is False and status != self.GATE_EMPTY: + v_gate_status[gate] = self.GATE_EMPTY + return v_gate_status + + # Use post-gear sensors to correct the selected gate. + # Returns the unique detected gate index, or None if zero/multiple detected. + def _validate_gate_selected(self): + gate = None + for g in range(self.num_gates): + if self.sensor_manager.check_all_sensors_before(self.FILAMENT_POS_START_BOWDEN, g, loading=True) is True: + if gate is None: + gate = g + else: + return None + return gate + + def _get_filament_char(self, gate, no_space=False, show_source=False): + show_source &= self.has_filament_buffer + gate_status = self.gate_status[gate] + if self.endless_spool_enabled and gate == self.endless_spool_eject_gate: + return "W" + elif gate_status == self.GATE_AVAILABLE_FROM_BUFFER: + return "B" if show_source else "*" + elif gate_status == self.GATE_AVAILABLE: + return "S" if show_source else "*" + elif gate_status == self.GATE_EMPTY: + return (UI_SEPARATOR if no_space else " ") + else: + return "?" + + def _ttg_map_to_string(self, tool=None, show_groups=True): + if show_groups: + msg = "TTG Map & EndlessSpool Groups:\n" + else: + msg = "TTG Map:\n" # String used to filter in KS-HH + num_tools = self.num_gates + tools = range(num_tools) if tool is None else [tool] + for i in tools: + gate = self.ttg_map[i] + filament_char = self._get_filament_char(gate, show_source=False) + msg += "\n" if i and tool is None else "" + msg += "T{:<2}-> Gate{:>2}({})".format(i, gate, filament_char) + + if show_groups and self.endless_spool_enabled: + group = self.endless_spool_groups[gate] + msg += " Group %s:" % chr(ord('A') + group) + gates_in_group = [(j + gate) % num_tools for j in range(num_tools)] + #msg += " >".join("{:>2}({})".format(g, self._get_filament_char(g, show_source=False)) for g in gates_in_group if self.endless_spool_groups[g] == group) + msg += " >".join("{:>2}".format(g) for g in gates_in_group if self.endless_spool_groups[g] == group) + + if i == self.tool_selected: + msg += " [SELECTED]" + return msg + + def _mmu_visual_to_string(self): + divider = UI_SPACE + UI_SEPARATOR + UI_SPACE + c_sum = 0 + msg_units = "Unit : " + msg_gates = "Gate : " + msg_avail = "Avail: " + msg_tools = "Tools: " + msg_selct = "Selct: " + for unit_index, gate_count in enumerate(self.mmu_machine.gate_counts): + gate_indices = range(c_sum, c_sum + gate_count) + c_sum += gate_count + last_gate = gate_indices[-1] == self.num_gates - 1 + sep = ("|" + divider) if not last_gate else "|" + tool_strings = [] + select_strings = [] + for g in gate_indices: + msg_gates += "".join("|{:^3}".format(g) if g < 10 else "| {:2}".format(g)) + msg_avail += "".join("| %s " % self._get_filament_char(g, no_space=True, show_source=True)) + tool_str = "+".join("T%d" % t for t in range(self.num_gates) if self.ttg_map[t] == g) + tool_strings.append(("|%s " % (tool_str if tool_str else " {} ".format(UI_SEPARATOR)))[:4]) + if self.gate_selected == g and self.gate_selected != self.TOOL_GATE_UNKNOWN: + select_strings.append("|\%s/|" % (UI_SEPARATOR if self.filament_pos < self.FILAMENT_POS_START_BOWDEN else "*")) + else: + select_strings.append("----") + unit_str = "{0:-^{width}}".format( " " + str(unit_index) + " ", width=len(gate_indices) * 4 + 1) + msg_units += unit_str + (divider if not last_gate else "") + msg_gates += sep + msg_avail += sep + msg_tools += "".join(tool_strings) + sep + msg_selct += ("".join(select_strings) + "-")[:len(gate_indices) * 4 + 1] + (divider if not last_gate else "") + lines = [msg_units] if len(self.mmu_machine.units) > 1 else [] + lines.extend([msg_gates, msg_tools, msg_avail, msg_selct]) + msg = "\n".join(lines) + if self.selector.is_homed: + msg += " " + self.selected_tool_string() + else: + msg += " NOT HOMED" + return msg + + def _es_groups_to_string(self, title=None): + msg = "%s:\n" % title if title else "EndlessSpool Groups:\n" + groups = {} + for gate in range(self.num_gates): + group = self.endless_spool_groups[gate] + if group not in groups: + groups[group] = [gate] + else: + groups[group].append(gate) + msg += "\n".join( + "Group %s: Gates: %s" % (chr(ord('A') + group), ", ".join(map(str, gates))) + for group, gates in groups.items() + ) + return msg + + def _gate_map_to_string(self, detail=False): + msg = "Gates / Filaments:" # String used to filter in KS-HH + available_status = { + self.GATE_AVAILABLE_FROM_BUFFER: "Buffer", + self.GATE_AVAILABLE: "Spool", + self.GATE_EMPTY: "Empty", + self.GATE_UNKNOWN: "Unknown" + } + + for g in range(self.num_gates): + available = available_status[self.gate_status[g]] + name = self.gate_filament_name[g] or "Unknown" + material = self.gate_material[g] or "Unknown" + color = self._format_color(self.gate_color[g] or "n/a") + temperature = self.gate_temperature[g] or "n/a" + + gate_fstr = "" + if detail: + filament_char = self._get_filament_char(g, show_source=False) + tools = ",".join("T{}".format(t) for t in range(self.num_gates) if self.ttg_map[t] == g) + tools_fstr = (" [{}]".format(tools) if tools else "") + gate_fstr = "{}".format(g).ljust(2, UI_SPACE) + gate_fstr = "{}({}){}:".format(gate_fstr, filament_char, tools_fstr).ljust(15, UI_SPACE) + else: + gate_fstr = "{}:".format(g).ljust(3, UI_SPACE) + + available_fstr = "{};".format(available).ljust(9, UI_SPACE) + fil_fstr = "{} | {}{}C | {} | {}".format(material, temperature, UI_DEGREE, color, name) + + spool_option = (str(self.gate_spool_id[g]) if self.gate_spool_id[g] > 0 else "n/a") + if self.spoolman_support == self.SPOOLMAN_OFF: + spool_fstr = "" + elif self.gate_spool_id[g] <= 0: + spool_fstr = "Id: {};".format(spool_option).ljust(12, UI_SPACE) + else: + spool_fstr = "Id: {}".format(spool_option).ljust(8, UI_SPACE) + "--> " + + speed_fstr = " [Speed:{}%]".format(self.gate_speed_override[g]) if self.gate_speed_override[g] != 100 else "" + extra_fstr = " [Selected]" if detail and g == self.gate_selected else "" + + msg += "\n{}{}{}{}{}{}".format(gate_fstr, available_fstr, spool_fstr, fil_fstr, speed_fstr, extra_fstr) + return msg + + # Remap a tool/gate relationship and gate filament availability + def _remap_tool(self, tool, gate, available=None): + self.ttg_map = list(self.ttg_map) # Ensure that webhook sees get_status() change + self.ttg_map[tool] = gate + self._persist_ttg_map() + self._ensure_ttg_match() + self._update_slicer_color_rgb() # Indexed by gate + if available is not None: + self._set_gate_status(gate, available) + + # Find and set a tool that maps to gate (for recovery) + def _ensure_ttg_match(self): + if self.gate_selected in [self.TOOL_GATE_UNKNOWN, self.TOOL_GATE_BYPASS]: + self._set_tool_selected(self.gate_selected) + else: + possible_tools = [tool for tool in range(self.num_gates) if self.ttg_map[tool] == self.gate_selected] + if possible_tools: + if self.tool_selected not in possible_tools: + self.log_debug("Resetting tool selected to match TTG map for current gate (%d)" % self.gate_selected) + self._set_tool_selected(possible_tools[0]) + else: + self.log_warning("Resetting tool selected to unknown because current gate (%d) isn't associated with tool in TTG map" % self.gate_selected) + self._set_tool_selected(self.TOOL_GATE_UNKNOWN) + + def _persist_ttg_map(self): + self.save_variable(self.VARS_MMU_TOOL_TO_GATE_MAP, self.ttg_map, write=True) + + def _reset_ttg_map(self): + self.log_debug("Resetting TTG map") + self.ttg_map = list(self.default_ttg_map) + self._persist_ttg_map() + self._ensure_ttg_match() + self._update_slicer_color_rgb() # Indexed by gate + + def _persist_endless_spool(self): + self.save_variable(self.VARS_MMU_ENABLE_ENDLESS_SPOOL, self.endless_spool_enabled) + self.save_variable(self.VARS_MMU_ENDLESS_SPOOL_GROUPS, self.endless_spool_groups) + self.write_variables() + + def _reset_endless_spool(self): + self.log_debug("Resetting Endless Spool mapping") + self.endless_spool_enabled = self.default_endless_spool_enabled + self.endless_spool_groups = list(self.default_endless_spool_groups) + self._persist_endless_spool() + + def _set_gate_status(self, gate, state): + if 0 <= gate < self.num_gates: + if state != self.gate_status[gate]: + self.gate_status = list(self.gate_status) # Ensure that webhooks sees get_status() change + self.gate_status[gate] = state + self._persist_gate_status() + self.led_manager.gate_map_changed(gate) + self.mmu_macro_event(self.MACRO_EVENT_GATE_MAP_CHANGED, "GATE=%d" % gate) + + def _persist_gate_status(self): + self.save_variable(self.VARS_MMU_GATE_STATUS, self.gate_status, write=True) + + # Ensure that webhooks sees get_status() change after gate map update. It is important to call this prior to + # updating gate_map so change is always seen. This approach removes need to copy lists on every call to get_status() + def _renew_gate_map(self): + self.gate_status = list(self.gate_status) + self.gate_filament_name = list(self.gate_filament_name) + self.gate_material = list(self.gate_material) + self.gate_color = list(self.gate_color) + self.gate_temperature = list(self.gate_temperature) + self.gate_spool_id = list(self.gate_spool_id) + self.gate_speed_override = list(self.gate_speed_override) + + def _persist_gate_map(self, spoolman_sync=False, gate_ids=None): + self.save_variable(self.VARS_MMU_GATE_STATUS, self.gate_status) + self.save_variable(self.VARS_MMU_GATE_FILAMENT_NAME, self.gate_filament_name) + self.save_variable(self.VARS_MMU_GATE_MATERIAL, self.gate_material) + self.save_variable(self.VARS_MMU_GATE_COLOR, self.gate_color) + self.save_variable(self.VARS_MMU_GATE_TEMPERATURE, self.gate_temperature) + self.save_variable(self.VARS_MMU_GATE_SPOOL_ID, self.gate_spool_id) + self.save_variable(self.VARS_MMU_GATE_SPEED_OVERRIDE, self.gate_speed_override) + self.write_variables() + self._update_t_macros() + + # Also persist to spoolman db if pushing updates for visability + if spoolman_sync: + if self.spoolman_support == self.SPOOLMAN_PUSH: + if gate_ids is None: + gate_ids = list(enumerate(self.gate_spool_id)) + if gate_ids: + self._spoolman_push_gate_map(gate_ids) + elif self.spoolman_support == self.SPOOLMAN_READONLY: + self._spoolman_update_filaments(gate_ids) + + self._moonraker_push_lane_data(gate_ids) + + self.led_manager.gate_map_changed(None) + if self.printer.lookup_object("gcode_macro %s" % self.mmu_event_macro, None) is not None: + self.mmu_macro_event(self.MACRO_EVENT_GATE_MAP_CHANGED, "GATE=-1") + + def _reset_gate_map(self): + self.log_debug("Resetting gate map") + self.gate_status = self._validate_gate_status(self.default_gate_status) + self.gate_filament_name = list(self.default_gate_filament_name) + self.gate_material = list(self.default_gate_material) + self.gate_color = list(self.default_gate_color) + self.gate_temperature = list(self.default_gate_temperature) + if self.spoolman_support in [self.SPOOLMAN_OFF, self.SPOOLMAN_PULL]: + self.gate_spool_id = [-1] * self.num_gates + else: + self.gate_spool_id = list(self.default_gate_spool_id) + self.gate_speed_override = list(self.default_gate_speed_override) + self._update_gate_color_rgb() + self._persist_gate_map(spoolman_sync=True) + + def _automap_gate(self, tool, strategy): + if tool is None: + self.log_error("Automap tool called without a tool argument") + return + tool_to_remap = self.slicer_tool_map['tools'][str(tool)] + # strategy checks + if strategy in ['spool_id']: + self.log_error("'%s' automapping strategy is not yet supported. Support for this feature is on the way, please be patient." % strategy) + return + + # Create printable strategy string + strategy_str = strategy.replace("_", " ").title() + + # Deduct search_in and tool_field based on strategy + # tool fields are like {'color': color, 'material': material, 'temp': temp, 'name': name, 'in_use': used} + if strategy == self.AUTOMAP_FILAMENT_NAME: + search_in = self.gate_filament_name + tool_field = 'name' + elif strategy == self.AUTOMAP_SPOOL_ID: + search_in = self.gate_spool_id + tool_field = 'spool_id' # Placeholders for future support + elif strategy == self.AUTOMAP_MATERIAL: + search_in = self.gate_material + tool_field = 'material' + elif strategy in [self.AUTOMAP_CLOSEST_COLOR, self.AUTOMAP_COLOR]: + search_in = self.gate_color + tool_field = 'color' + else: + self.log_error("Invalid automap strategy '%s'" % strategy) + return + + # Automapping logic + errors = [] + warnings = [] + messages = [] + remaps = [] + + if not tool_to_remap[tool_field]: + errors.append("%s of tool %s must be set. When using automapping all referenced tools must have a %s" % (tool_field, tool, strategy_str)) + + if not errors: + # 'standard' exactly matching fields + if strategy != self.AUTOMAP_CLOSEST_COLOR: + for gn, gate_feature in enumerate(search_in): + # When matching by name normalize possible unicode characters and match case-insensitive + if strategy == self.AUTOMAP_FILAMENT_NAME: + equal = self._compare_unicode(tool_to_remap[tool_field], gate_feature) + elif strategy == self.AUTOMAP_COLOR: + equal = tool_to_remap[tool_field].upper().ljust(8,'F') == gate_feature.upper().ljust(8,'F') + else: + equal = tool_to_remap[tool_field] == gate_feature + if equal: + remaps.append("T%s --> G%s (%s)" % (tool, gn, gate_feature)) + self.wrap_gcode_command("MMU_TTG_MAP TOOL=%d GATE=%d QUIET=1" % (tool, gn)) + if not remaps: + errors.append("No gates found for tool %s with %s %s" % (tool, strategy_str, tool_to_remap[tool_field])) + + # 'colors' search for closest + elif strategy == self.AUTOMAP_CLOSEST_COLOR: + if tool_to_remap['material'] == "unknown": + errors.append("When automapping with closest color, the tool material must be set.") + if tool_to_remap['material'] not in self.gate_material: + errors.append("No gate has a filament matching the desired material (%s). Available are : %s" % (tool_to_remap['material'], self.gate_material)) + if not errors: + color_list = [] + for gn, color in enumerate(search_in): + gm = "".join(self.gate_material[gn].strip()).replace('#', '').lower() + if gm == tool_to_remap['material'].lower(): + color_list.append(color) + if not color_list: + errors.append("Gates with %s are missing color information..." % tool_to_remap['material']) + + if not errors: + closest, distance = self._find_closest_color(tool_to_remap['color'], color_list) + for gn, color in enumerate(search_in): + gm = "".join(self.gate_material[gn].strip()).replace('#', '').lower() + if gm == tool_to_remap['material'].lower(): + if closest == color: + t = self.console_gate_stat + if distance > 0.5: + warnings.append("Color matching is significantly different ! %s" % (UI_EMOTICONS[7] if t == 'emoticon' else '')) + elif distance > 0.2: + warnings.append("Color matching might be noticebly different %s" % (UI_EMOTICONS[5] if t == 'emoticon' else '')) + elif distance > 0.05: + warnings.append("Color matching seems quite good %s" % (UI_EMOTICONS[3] if t == 'emoticon' else '')) + elif distance > 0.02: + warnings.append("Color matching is excellent %s" % (UI_EMOTICONS[2] if t == 'emoticon' else '')) + elif distance < 0.02: + warnings.append("Color matching is perfect %s" % (UI_EMOTICONS[1] if t == 'emoticon' else '')) + remaps.append("T%s --> G%s (%s with closest color: %s)" % (tool, gn, gm, color)) + self.wrap_gcode_command("MMU_TTG_MAP TOOL=%d GATE=%d QUIET=1" % (tool, gn)) + + if not remaps: + errors.append("Unable to find a suitable color for tool %s (color: %s)" % (tool, tool_to_remap['color'])) + if len(remaps) > 1: + warnings.append("Multiple gates found for tool %s with %s '%s'" % (tool, strategy_str, tool_to_remap[tool_field])) + + # Display messages while automapping + if remaps: + remaps.insert(0, "Automatically mapped tool %s based on %s" % (tool, strategy_str)) + for msg in remaps: + self.log_always(msg) + if messages: + for msg in messages: + self.log_always(msg) + # Display warnings while automapping + for msg in warnings: + self.log_info(msg) + # Display errors while automapping + if errors: + reason = ["Error during automapping"] + if self.is_printing(): + self.handle_mmu_error("\n".join(reason+errors)) + else: + self.log_error(reason[0]) + for e in errors: + self.log_error(e) + + # Set 'color' and 'spool_id' variable on the Tx macro for Mainsail/Fluidd to pick up + # We don't use SET_GCODE_VARIABLE because the macro variable may not exist ahead of time + def _update_t_macros(self): + for tool in range(self.num_gates): + gate = self.ttg_map[tool] + t_macro = self.printer.lookup_object("gcode_macro T%d" % tool, None) + + if t_macro: + t_vars = dict(t_macro.variables) # So Mainsail sees the update + + spool_id = self.gate_spool_id[gate] + if (self.t_macro_color != self.T_MACRO_COLOR_OFF and + spool_id >= 0 and + self.spoolman_support != self.SPOOLMAN_OFF and + self.gate_status[gate] != self.GATE_EMPTY): + + t_vars['spool_id'] = self.gate_spool_id[gate] + else: + t_vars.pop('spool_id', None) + + if self.t_macro_color == self.T_MACRO_COLOR_SLICER: + st = self.slicer_tool_map['tools'].get(str(tool), None) + rgb_hex = self._color_to_rgb_hex(st.get('color', None)) if st else None + if rgb_hex: + t_vars['color'] = rgb_hex + else: + t_vars.pop('color', None) + + elif self.t_macro_color in [self.T_MACRO_COLOR_GATEMAP, self.T_MACRO_COLOR_ALLGATES]: + rgb_hex = self._color_to_rgb_hex(self.gate_color[gate]) + if self.gate_status[gate] != self.GATE_EMPTY or self.t_macro_color == self.T_MACRO_COLOR_ALLGATES: + t_vars['color'] = rgb_hex + else: + t_vars.pop('color', None) + + else: # 'off' case + t_vars.pop('color', None) + + t_macro.variables = t_vars + +### GCODE COMMANDS FOR RUNOUT, TTG MAP, GATE MAP and GATE LOGIC ################## + + cmd_MMU_TEST_RUNOUT_help = "Manually invoke the clog/runout detection logic for testing" + def cmd_MMU_TEST_RUNOUT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + event_type = gcmd.get('TYPE', None) + try: + with self.wrap_sync_gear_to_extruder(): + self._runout(event_type=event_type, sensor="TEST") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_ENCODER_RUNOUT_help = "Internal encoder filament runout handler" + def cmd_MMU_ENCODER_RUNOUT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + return + self._fix_started_state() + try: + with self.wrap_sync_gear_to_extruder(): + self._runout(sensor="Encoder") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_ENCODER_INSERT_help = "Internal encoder filament insert detection handler" + def cmd_MMU_ENCODER_INSERT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: return + # TODO Possible future bypass preload feature - make gate act like bypass + + # Callback to handle runout event from an MMU sensors. Note that pause_resume.send_pause_command() + # will have already been issued but no PAUSE command + # Params: + # EVENTTIME will contain reactor time that the sensor triggered and command was queued + # SENSOR will contain sensor name + # GATE will be set if specific pre-gate or gear sensor + cmd_MMU_SENSOR_RUNOUT_help= "Internal MMU filament runout handler" + def cmd_MMU_SENSOR_RUNOUT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + return + self._fix_started_state() + eventtime = gcmd.get_float('EVENTTIME', self.reactor.monotonic()) + gate = gcmd.get_int('GATE', None) + raw_sensor = gcmd.get('SENSOR', "") + sensor = self.sensor_manager.get_unitless_sensor_name(raw_sensor) + process_runout = False + + try: + with self.wrap_sync_gear_to_extruder(): + if eventtime < self.runout_last_enable_time: + self.log_debug("Assertion failure: Late sensor runout event on %s. Ignored" % raw_sensor) + + elif sensor and self.sensor_manager.check_sensor(sensor): + self.log_debug("Assertion failure: Runout handler suspects sensor malfunction on %s. Ignored" % raw_sensor) + + else: + # Always update gate map from pre-gate sensor + if sensor.startswith(self.SENSOR_PRE_GATE_PREFIX) and gate != self.gate_selected: + self._set_gate_status(gate, self.GATE_EMPTY) + + # Real runout to process... + if sensor.startswith(self.SENSOR_PRE_GATE_PREFIX) and gate == self.gate_selected: + if self.endless_spool_enabled and self.endless_spool_eject_gate == gate: + self.log_trace("Ignoring filament runout detected by %s because endless_spool_eject_gate is active on that gate" % raw_sensor) + else: + process_runout = True + + elif sensor == self.SENSOR_GATE and gate is None: + process_runout = True + + elif sensor.startswith(self.SENSOR_GEAR_PREFIX) and gate == self.gate_selected: + process_runout = True + + elif sensor.startswith(self.SENSOR_EXTRUDER_ENTRY): + raise MmuError("Filament runout occured at extruder. Manual intervention is required") + + else: + self.log_debug("Assertion failure: Unexpected/unhandled sensor runout event on %s. Ignored" % raw_sensor) + + if process_runout: + self._runout(event_type="runout", sensor=sensor) # Will send_resume_command() or fail and pause + else: + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_SENSOR_CLOG_help= "Internal MMU filament clog handler" + def cmd_MMU_SENSOR_CLOG(self, gcmd): + try: + with self.wrap_sync_gear_to_extruder(): + self._clog_tangle(gcmd, "clog") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_SENSOR_TANGLE_help= "Internal MMU filament tangle handler" + def cmd_MMU_SENSOR_TANGLE(self, gcmd): + try: + with self.wrap_sync_gear_to_extruder(): + self._clog_tangle(gcmd, "tangle") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + # Common callback to handle clog/tangle event from an MMU sensors. + # Note that pause_resume.send_pause_command() will have already been issued but no PAUSE command + # Params: + # EVENTTIME will contain reactor time that the sensor triggered and command was queued + # SENSOR will contain sensor name + def _clog_tangle(self, gcmd, event_type): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + return + self._fix_started_state() + eventtime = gcmd.get_float('EVENTTIME', self.reactor.monotonic()) + sensor = gcmd.get('SENSOR', "") + self._runout(event_type=event_type, sensor=sensor) # Will send_resume_command() or fail and pause + + # Callback to handle insert event from an MMU sensor + # Params: + # EVENTTIME will contain reactor time that the sensor triggered and command was queued + # SENSOR will contain sensor name + # GATE will be set if specific pre-gate or gear sensor + cmd_MMU_SENSOR_INSERT_help= "Internal MMU filament insertion handler" + def cmd_MMU_SENSOR_INSERT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: return + self._fix_started_state() + eventtime = gcmd.get_float('EVENTTIME', self.reactor.monotonic()) + gate = gcmd.get_int('GATE', None) + raw_sensor = gcmd.get('SENSOR', "") + sensor = self.sensor_manager.get_unitless_sensor_name(raw_sensor) + + try: + with self.wrap_sync_gear_to_extruder(): + if sensor.startswith(self.SENSOR_PRE_GATE_PREFIX) and gate is not None: + self._set_gate_status(gate, self.GATE_UNKNOWN) + self._check_pending_spool_id(gate) # Have spool_id ready? + if not self.is_printing() and self.gate_autoload: + self.gcode.run_script_from_command("MMU_PRELOAD GATE=%d" % gate) + + elif sensor == self.SENSOR_EXTRUDER_ENTRY: + if self.gate_selected != self.TOOL_GATE_BYPASS: + msg = "bypass not selected" + elif self.is_printing(): + msg = "actively printing" # Should not get here! + elif self.filament_pos != self.FILAMENT_POS_UNLOADED: + msg = "extruder cannot be verified as unloaded. Try running MMU_RECOVER to fix state" + elif not self.bypass_autoload: + msg = "bypass autoload is disabled" + else: + self.log_debug("Autoloading extruder") + with self._wrap_suspend_filament_monitoring(): + self._note_toolchange("> Bypass") + self.load_sequence(bowden_move=0., extruder_only=True, purge=self.PURGE_NONE) # TODO PURGE_STANDALONE? + return + self.log_debug("Ignoring extruder insertion because %s" % msg) + + else: + self.log_debug("Assertion failure: Unexpected/unhandled sensor insert event on %s. Ignored" % raw_sensor) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + # Callback to handle removal event from an MMU sensor (only mmu_pre_gate for now). A removal + # event can happen both in an out of a print + # Params: + # EVENTTIME will contain reactor time that the sensor triggered and command was queued + # SENSOR will contain sensor name + # GATE will be set if specific pre-gate or gear sensor + cmd_MMU_SENSOR_REMOVE_help= "Internal MMU filament removal handler" + def cmd_MMU_SENSOR_REMOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: return + self._fix_started_state() + eventtime = gcmd.get_float('EVENTTIME', self.reactor.monotonic()) + gate = gcmd.get_int('GATE', None) + raw_sensor = gcmd.get('SENSOR', "") + sensor = self.sensor_manager.get_unitless_sensor_name(raw_sensor) + + try: + with self.wrap_sync_gear_to_extruder(): + if sensor.startswith(self.SENSOR_PRE_GATE_PREFIX) and gate is not None: + # Ignore pre-gate runout if endless_spool_eject_gate feature is active and we want filament to be consumed to clear gate + if not(self.endless_spool_enabled and self.endless_spool_eject_gate > 0): + self._set_gate_status(gate, self.GATE_EMPTY) + else: + self.log_trace("Ignoring filament removal detected by %s because endless_spool_eject_gate is active" % raw_sensor) + + else: + self.log_debug("Assertion failure: Unexpected/unhandled sensor remove event on %s. Ignored" % raw_sensor) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_M400_help = "Wait on both move queues" + def cmd_MMU_M400(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + self.mmu_toolhead.quiesce() + + cmd_MMU_TTG_MAP_help = "aka MMU_REMAP_TTG Display or remap a tool to a specific gate and set gate availability" + def cmd_MMU_TTG_MAP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + ttg_map = gcmd.get('MAP', "!") + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates - 1) + available = gcmd.get_int('AVAILABLE', self.GATE_UNKNOWN, minval=self.GATE_EMPTY, maxval=self.GATE_AVAILABLE) + + try: + if reset == 1: + self._reset_ttg_map() + elif ttg_map != "!": + ttg_map = gcmd.get('MAP').split(",") + if len(ttg_map) != self.num_gates: + self.log_always("The number of map values (%d) is not the same as number of gates (%d)" % (len(ttg_map), self.num_gates)) + return + self.ttg_map = [] + for gate in ttg_map: + if gate.isdigit(): + self.ttg_map.append(int(gate)) + else: + self.ttg_map.append(0) + self._persist_ttg_map() + elif gate != -1: + status = self.gate_status[gate] + if not available == self.GATE_UNKNOWN or (available == self.GATE_UNKNOWN and status == self.GATE_EMPTY): + status = available + if tool == -1: + self._set_gate_status(gate, status) + else: + self._remap_tool(tool, gate, status) + else: + quiet = False # Display current TTG map + if not quiet: + msg = self._ttg_map_to_string(show_groups=detail) + if not detail and self.endless_spool_enabled: + msg += "\nDETAIL=1 to see EndlessSpool map" + self.log_info(msg) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_GATE_MAP_help = "Display or define the type and color of filaments on each gate" + def cmd_MMU_GATE_MAP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + gates = gcmd.get('GATES', "!") + gmapstr = gcmd.get('MAP', "{}") # Hidden option for bulk filament update (from moonraker/ui components) + replace = bool(gcmd.get_int('REPLACE', 0, minval=0, maxval=1)) # Hidden option for bulk filament update from spoolman + from_spoolman = bool(gcmd.get_int('FROM_SPOOLMAN', 0, minval=0, maxval=1)) # Hidden option for bulk filament update from spoolman + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + next_spool_id = gcmd.get_int('NEXT_SPOOLID', None, minval=-1) + + gate_map = None + try: + gate_map = ast.literal_eval(gmapstr) + except Exception as e: + self.log_error("Recieved unparsable gate map update. See log for more details") + self.log_debug("Exception whilst parsing gate map in MMU_GATE_MAP: %s" % str(e)) + return + + if reset: + self._reset_gate_map() + else: + self._renew_gate_map() # Ensure that webhooks sees changes + + if next_spool_id: + if self.spoolman_support != self.SPOOLMAN_PULL: + if next_spool_id > 0: + self.pending_spool_id = next_spool_id + self.reactor.update_timer(self.pending_spool_id_timer, self.reactor.monotonic() + self.pending_spool_id_timeout) + else: + # Disable timer to prevent reuse + self.pending_spool_id = -1 + self.reactor.update_timer(self.pending_spool_id_timer, self.reactor.NEVER) + else: + self.log_error("Cannot use use NEXT_SPOOLID feature with spoolman_support: pull. Use 'push' or 'readonly' modes") + return + + changed_gate_ids = [] + + if gate_map: # --------- BATCH UPDATE from spoolman or UI -------- + try: + self.log_debug("Received gate map update (replace: %s)" % replace) + if replace: + # Replace complete map including spool_id (should only be in spoolman "pull" mode) + if self.spoolman_support != self.SPOOLMAN_PULL: + self.mmu.log_debug("Assertion failure: received gate map replacement update but not in spoolman 'pull' mode") + + # If from spoolman gate_map should be a full gate list with spool_id = -1 for unset gates + for gate, fil in gate_map.items(): + if not (0 <= gate < self.num_gates): + self.log_debug("Warning: Illegal gate number %d supplied in gate map update - ignored" % gate) + continue + + # Update gate attributes if we have valid spool_id + spool_id = self.safe_int(fil.get('spool_id', -1)) + self.gate_spool_id[gate] = spool_id + self.gate_filament_name[gate] = fil.get('name', '') + self.gate_material[gate] = fil.get('material', '') + self.gate_color[gate] = fil.get('color', '') + self.gate_temperature[gate] = max( + self.safe_int(fil.get('temp', self.default_extruder_temp)), + self.default_extruder_temp + ) + # gate_speed_override and gate_status can be set locally + else: + # Update map (ui or from spoolman in "readonly" and "push" modes) + ids_dict = {} + for gate, fil in gate_map.items(): + if not (0 <= gate < self.num_gates): + self.log_debug("Warning: Illegal gate number %d supplied in gate map update - ignored" % gate) + continue + + spool_id = self.safe_int(fil.get('spool_id', -1)) + if (not from_spoolman or spool_id != -1): + # Update attributes but don't allow spoolman to accidently clear + self.gate_filament_name[gate] = fil.get('name', '') + self.gate_material[gate] = fil.get('material', '') + self.gate_color[gate] = fil.get('color', '') + self.gate_temperature[gate] = max( + self.safe_int(fil.get('temp', self.default_extruder_temp)), + self.default_extruder_temp + ) + self.gate_speed_override[gate] = self.safe_int(fil.get('speed_override', self.gate_speed_override[gate])) + self.gate_status[gate] = self.safe_int(fil.get('status', self.gate_status[gate])) # For UI manual fixing of availabilty + + # If spool_id has changed, clean up possible stale use of old one + if spool_id != self.gate_spool_id[gate]: + self.log_debug("Spool_id changed for gate %d in MMU_GATE_MAP" % gate) + mod_gate_ids = self.assign_spool_id(gate, spool_id) + for (gate, sid) in mod_gate_ids: + ids_dict[gate] = sid + + changed_gate_ids = list(ids_dict.items()) + + except Exception as e: + self.log_debug("Invalid MAP parameter: %s\nException: %s" % (gate_map, str(e))) + raise gcmd.error("Invalid MAP parameter. See mmu.log for details") + + elif gates != "!" or gate >= 0: + gatelist = [] + if gates != "!": + # List of gates + try: + for gate in gates.split(','): + gate = int(gate) + if 0 <= gate < self.num_gates: + gatelist.append(gate) + except ValueError: + raise gcmd.error("Invalid GATES parameter: %s" % gates) + else: + # Specifying one gate (filament) + gatelist.append(gate) + + ids_dict = {} + for gate in gatelist: + available = gcmd.get_int('AVAILABLE', self.gate_status[gate], minval=-1, maxval=2) + name = gcmd.get('NAME', None) + material = gcmd.get('MATERIAL', None) + color = gcmd.get('COLOR', None) + spool_id = gcmd.get_int('SPOOLID', None, minval=-1) + temperature = gcmd.get_int('TEMP', int(self.default_extruder_temp)) + speed_override = gcmd.get_int('SPEED', self.gate_speed_override[gate], minval=10, maxval=150) + + if self.spoolman_support != self.SPOOLMAN_PULL: + # Local gate map, can update attributes + spool_id = spool_id or self.gate_spool_id[gate] + name = name if name is not None else self.gate_filament_name[gate] + material = (material if material is not None else self.gate_material[gate]).upper() + color = (color if color is not None else self.gate_color[gate]).lower() + temperature = temperature or self.gate_temperature[gate] + color = self._validate_color(color) + if color is None: + raise gcmd.error("Color specification must be in form 'rrggbb' or 'rrggbbaa' hexadecimal value (no '#') or valid color name or empty string") + self.gate_filament_name[gate] = name + self.gate_material[gate] = material + self.gate_color[gate] = color + self.gate_temperature[gate] = temperature + self.gate_speed_override[gate] = speed_override + self.gate_status[gate] = available + + if spool_id != self.gate_spool_id[gate]: + self.log_debug("Spool_id changed for gate %d in MMU_GATE_MAP" % gate) + mod_gate_ids = self.assign_spool_id(gate, spool_id) + for (gate, sid) in mod_gate_ids: + ids_dict[gate] = sid + + else: + # Remote (spoolman) gate map, don't update local attributes that are set by spoolman + self.gate_status[gate] = available + self.gate_speed_override[gate] = speed_override + if any(x is not None for x in [material, color, spool_id, name]): + self.log_error("Spoolman mode is '%s': Can only set gate status and speed override locally\nUse MMU_SPOOLMAN or update spoolman directly" % self.SPOOLMAN_PULL) + return + + changed_gate_ids = list(ids_dict.items()) + + # Ensure everything is synced + self._update_gate_color_rgb() + + # Caution, make sure that an update from spoolman does end up in infinite loop! + self._persist_gate_map(spoolman_sync=bool(changed_gate_ids) and not from_spoolman, gate_ids=changed_gate_ids) # This will also update LED status + + if not quiet: + self.log_info(self._gate_map_to_string(detail)) + + cmd_MMU_ENDLESS_SPOOL_help = "Diplay or Manage EndlessSpool functionality and groups" + def cmd_MMU_ENDLESS_SPOOL(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + enabled = gcmd.get_int('ENABLE', -1, minval=0, maxval=1) + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + groups = gcmd.get('GROUPS', "!") + + if enabled >= 0: + self.endless_spool_enabled = enabled + self.save_variable(self.VARS_MMU_ENABLE_ENDLESS_SPOOL, self.endless_spool_enabled, write=True) + if enabled and not quiet: + self.log_always("EndlessSpool is enabled") + if not self.endless_spool_enabled: + self.log_always("EndlessSpool is disabled") + return + + if reset: + self._reset_endless_spool() + + elif groups != "!": + groups = gcmd.get('GROUPS', ",".join(map(str, self.endless_spool_groups))).split(",") + if len(groups) != self.num_gates: + self.log_always("The number of group values (%d) is not the same as number of gates (%d)" % (len(groups), self.num_gates)) + return + self.endless_spool_groups = [] + for group in groups: + if group.isdigit(): + self.endless_spool_groups.append(int(group)) + else: + self.endless_spool_groups.append(0) + self._persist_endless_spool() + + else: + quiet = False # Display current map + + if not quiet: + self.log_info(self._es_groups_to_string()) + + cmd_MMU_TOOL_OVERRIDES_help = "Displays, sets or clears tool speed and extrusion factors (M220 & M221)" + def cmd_MMU_TOOL_OVERRIDES(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates) + speed = gcmd.get_int('M220', None, minval=0, maxval=200) + extrusion = gcmd.get_int('M221', None, minval=0, maxval=200) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + + if reset: + self._set_tool_override(tool, 100, 100) + elif tool >= 0: + self._set_tool_override(tool, speed, extrusion) + + msg_tool = "Tools: " + msg_sped = "M220 : " + msg_extr = "M221 : " + for i in range(self.num_gates): + range_end = 6 if i > 9 else 5 + tool_speed = int(self.tool_speed_multipliers[i] * 100) + tool_extr = int(self.tool_extrusion_multipliers[i] * 100) + msg_tool += ("| T%d " % i)[:range_end] + msg_sped += ("| %d " % tool_speed)[:range_end] + msg_extr += ("| %d " % tool_extr)[:range_end] + msg = "|\n".join([msg_tool, msg_sped, msg_extr]) + "|\n" + self.log_always(msg) + + cmd_MMU_SLICER_TOOL_MAP_help = "Display or define the tools used in print as specified by slicer" + def cmd_MMU_SLICER_TOOL_MAP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self._fix_started_state() + + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + purge_map = bool(gcmd.get_int('PURGE_MAP', 0, minval=0, maxval=1)) + sparse_purge_map = bool(gcmd.get_int('SPARSE_PURGE_MAP', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + initial_tool = gcmd.get_int('INITIAL_TOOL', None, minval=0, maxval=self.num_gates - 1) + total_toolchanges = gcmd.get_int('TOTAL_TOOLCHANGES', None, minval=0) + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates - 1) + material = gcmd.get('MATERIAL', "unknown") + color = gcmd.get('COLOR', "").lower() + name = gcmd.get('NAME', "") # Filament name + temp = gcmd.get_int('TEMP', 0, minval=0) + used = bool(gcmd.get_int('USED', 1, minval=0, maxval=1)) # Is used in print (i.e a referenced tool or not) + purge_volumes = gcmd.get('PURGE_VOLUMES', "") + num_slicer_tools = gcmd.get_int('NUM_SLICER_TOOLS', self.num_gates, minval=1, maxval=self.num_gates) # Allow slicer to have less tools than MMU gates + automap_strategy = gcmd.get('AUTOMAP', None) + skip_automap = gcmd.get_int('SKIP_AUTOMAP', None, minval=0, maxval=1) + + quiet = False + if reset: + self._clear_slicer_tool_map() + quiet = True + else: + self.slicer_tool_map = dict(self.slicer_tool_map) # Ensure that webhook sees get_status() change + + # This is a "one-print" option that supresses automatic automap. If specified, set the skip option + # else leave it be. It will be reset at print end + if skip_automap is not None: + # This is a "one-print" option that supresses automatic automap + self._restore_automap_option(bool(skip_automap)) + + if tool >= 0: + self.slicer_tool_map['tools'][str(tool)] = {'color': color, 'material': material, 'temp': temp, 'name': name, 'in_use': used} + if used: + self.slicer_tool_map['referenced_tools'] = sorted(set(self.slicer_tool_map['referenced_tools'] + [tool])) + if not self.slicer_tool_map['skip_automap'] and automap_strategy and automap_strategy != self.AUTOMAP_NONE: + self._automap_gate(tool, automap_strategy) + if color: + self._update_slicer_color_rgb() + quiet = True + + if initial_tool is not None: + self.slicer_tool_map['initial_tool'] = initial_tool + self.slicer_tool_map['referenced_tools'] = sorted(set(self.slicer_tool_map['referenced_tools'] + [initial_tool])) + quiet = True + + if total_toolchanges is not None: + self.slicer_tool_map['total_toolchanges'] = total_toolchanges + quiet = True + + if purge_volumes != "": + try: + volumes = list(map(float, purge_volumes.split(','))) + n = len(volumes) + num_tools = self.num_gates + if n == 1: + calc = lambda x,y: volumes[0] * 2 # Build a single value matrix + elif n == num_slicer_tools: + calc = lambda x,y: volumes[y] + volumes[x] # Will build symmetrical purge matrix "from" followed by "to" + elif n == num_slicer_tools ** 2: + calc = lambda x,y: volumes[y + x * num_slicer_tools] # Full NxN matrix supplied in rows of "from" for each "to" + elif n == num_slicer_tools * 2: + calc = lambda x,y: volumes[y] + volumes[num_slicer_tools + x] # Build matrix with sum of "from" list then "to" list + else: + raise gcmd.error("Incorrect number of values for PURGE_VOLUMES. Expected 1, %d, %d, or %d, got %d" % (num_tools, num_tools * 2, num_tools ** 2, n)) + # Build purge volume map (x=to_tool, y=from_tool) + should_calc = lambda x,y: x < num_slicer_tools and y < num_slicer_tools and x != y + self.slicer_tool_map['purge_volumes'] = [ + [ + calc(x,y) if should_calc(x,y) else 0 + for y in range(self.num_gates) + ] + for x in range(self.num_gates) + ] + except ValueError as e: + raise gcmd.error("Error parsing PURGE_VOLUMES: %s" % str(e)) + quiet = True + + if not quiet: + colors = sum(1 for tool in self.slicer_tool_map['tools'] if self.slicer_tool_map['tools'][tool]['in_use']) + + have_purge_map = len(self.slicer_tool_map['purge_volumes']) > 0 + msg = "No slicer tool map loaded" + if colors > 0 or self.slicer_tool_map['initial_tool'] is not None: + msg = "--------- Slicer MMU Tool Summary ---------\n" + msg += "Single color print" if colors <= 1 else "%d color print" % colors + msg += " (Purge volume map loaded)\n" if colors > 1 and have_purge_map else "\n" + for t, params in self.slicer_tool_map['tools'].items(): + if params['in_use'] or detail: + msg += "T%d (gate %d, %s, %s, %d%sC)" % (int(t), self.ttg_map[int(t)], params['material'], params['color'], params['temp'], UI_DEGREE) + msg += " Not used\n" if detail and not params['in_use'] else "\n" + if self.slicer_tool_map['initial_tool'] is not None: + msg += "Initial Tool: T%d" % self.slicer_tool_map['initial_tool'] + msg += " (will use bypass)\n" if colors <= 1 and self.tool_selected == self.TOOL_GATE_BYPASS else "\n" + msg += "-------------------------------------------" + if detail or purge_map or sparse_purge_map: + if have_purge_map: + rt = self.slicer_tool_map['referenced_tools'] + volumes = [row[:num_slicer_tools] for row in self.slicer_tool_map['purge_volumes'][:num_slicer_tools]] + msg += "\nPurge Volume Map (mm^3):\n" + msg += "To ->" + UI_SEPARATOR.join("{}T{: <2}".format(UI_SPACE, i) for i in range(num_slicer_tools)) + "\n" + msg += '\n'.join([ + "T{: <2}{}{}".format(y, UI_SEPARATOR, ' '.join( + map(lambda v, x, y=y: str(round(v)).rjust(4, UI_SPACE) + if (not sparse_purge_map or (y in rt and x in rt)) and v > 0 + else "{}{}-{}".format(UI_SPACE, UI_SPACE, UI_SPACE), + row, range(len(row)) + ) + )) + for y, row in enumerate(volumes) + ]) + + elif have_purge_map: + msg += "\nDETAIL=1 to see purge volume map" + self.log_always(msg) + + cmd_MMU_CALC_PURGE_VOLUMES_help = "Calculate purge volume matrix based on filament color overriding slicer tool map import" + def cmd_MMU_CALC_PURGE_VOLUMES(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self._fix_started_state() + + min_purge = gcmd.get_int('MIN', 0, minval=0) + max_purge = gcmd.get_int('MAX', 800, minval=1) + multiplier = gcmd.get_float('MULTIPLIER', 1., above=0.) + source = gcmd.get('SOURCE', 'gatemap') + if source not in ['gatemap', 'slicer']: + raise gcmd.error("Invalid color source: %s. Options are: gatemap, slicer" % source) + if min_purge >= max_purge: + raise gcmd.error("MAX purge volume must be greater than MIN") + + tool_rgb_colors = [] + if source == 'slicer': + # Pull colors from existing slicer map + for tool in range(self.num_gates): + tool_info = self.slicer_tool_map['tools'].get(str(tool)) + if tool_info: + tool_rgb_colors.append(self._color_to_rgb_hex(tool_info.get('color', ''))) + else: + tool_rgb_colors.append(self._color_to_rgb_hex('')) + else: + # Logic to use tools mapped to gate colors with current ttg map + for tool in range(self.num_gates): + gate = self.ttg_map[tool] + tool_rgb_colors.append(self._color_to_rgb_hex(self.gate_color[gate])) + + try: + self.slicer_tool_map['purge_volumes'] = self._generate_purge_matrix(tool_rgb_colors, min_purge, max_purge, multiplier) + self.log_always("Purge map updated. Use 'MMU_SLICER_TOOL_MAP PURGE_MAP=1' to view") + except Exception as e: + raise MmuError("Error generating purge volues: %s" % str(e)) + + cmd_MMU_CHECK_GATE_help = "Automatically inspects gate(s), parks filament and marks availability" + def cmd_MMU_CHECK_GATE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_bypass(): return + self._fix_started_state() + + quiet = gcmd.get_int('QUIET', 0, minval=0, maxval=1) + # These three parameters are mutually exclusive so we only process one + tools = gcmd.get('TOOLS', "!") + gates = gcmd.get('GATES', "!") + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates - 1) + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + all_gates = gcmd.get_int('ALL', 0, minval=0, maxval=1) + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates = None if gate == -1 else [gate]): return # TODO Incomplete/simplified gate selection + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during gate check + with self._wrap_suspendwrite_variables(): # Reduce I/O activity to a minimum + with self.wrap_action(self.ACTION_CHECKING): + tool_selected = self.tool_selected + filament_pos = self.filament_pos + gates_tools = [] + if gate >= 0: + # Individual gate + gates_tools.append([gate, -1]) + elif tool >= 0: + # Individual tool + gate = self.ttg_map[tool] + gates_tools.append([gate, tool]) + elif all_gates: + for gate in range(self.num_gates): + gates_tools.append([gate, -1]) + elif gates != "!": + # List of gates + try: + for gate in gates.split(','): + gate = int(gate) + if 0 <= gate < self.num_gates: + gates_tools.append([gate, -1]) + except ValueError: + raise MmuError("Invalid GATES parameter: %s" % tools) + elif tools != "!": + # Tools used in print (may be empty list) + try: + for tool in tools.split(','): + if not tool == "": + tool = int(tool) + if 0 <= tool < self.num_gates: + gate = self.ttg_map[tool] + gates_tools.append([gate, tool]) + if len(gates_tools) == 0: + self.log_debug("No tools to check, assuming default tool is already loaded") + return + except ValueError: + raise MmuError("Invalid TOOLS parameter: %s" % tools) + elif self.gate_selected >= 0: + # No parameters means current gate + gates_tools.append([self.gate_selected, -1]) + else: + raise MmuError("Current gate is invalid") + + # Force initial eject + if filament_pos != self.FILAMENT_POS_UNLOADED: + self.log_info("Unloading current tool prior to checking gates") + + # Perform full unload sequence including parking + self._note_toolchange("< %s" % self.selected_tool_string()) + self.last_statistics = {} + self._save_toolhead_position_and_park('unload') + self._unload_tool(form_tip=self.FORM_TIP_STANDALONE) + self._persist_gate_statistics() + self._continue_after('unload') + + if len(gates_tools) > 1: + self.log_info("Will check gates: %s" % ', '.join(str(g) for g,t in gates_tools)) + with self.wrap_suppress_visual_log(): + self._set_tool_selected(self.TOOL_GATE_UNKNOWN) + for gate, tool in gates_tools: + try: + self.select_gate(gate) + self.log_info("Checking gate %d..." % gate) + _ = self._load_gate(allow_retry=False) + if tool >= 0: + self.log_info("Tool T%d - Filament detected. Gate %d marked available" % (tool, gate)) + else: + self.log_info("Gate %d - Filament detected. Marked available" % gate) + self._set_gate_status(gate, max(self.gate_status[gate], self.GATE_AVAILABLE)) + try: + _,_ = self._unload_gate() + except MmuError as ee: + raise MmuError("Failure during check gate %d %s:\n%s" % (gate, "(T%d)" % tool if tool >= 0 else "", str(ee))) + except MmuError as ee: + self._set_gate_status(gate, self.GATE_EMPTY) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=True) + if tool >= 0: + msg = "Tool T%d on gate %d marked EMPTY" % (tool, gate) + else: + msg = "Gate %d marked EMPTY" % gate + self.log_debug("Gate marked empty because: %s" % str(ee)) + if self.is_in_print(): + raise MmuError("%s%s" % ("Required " if self.is_printing() else "", msg)) + else: + self.log_always(msg) + finally: + self._initialize_encoder() # Encoder 0000 + + # If not printing select original tool and load filament if necessary + # We don't do this when printing because this is expected to preceed loading initial tool + if not self.is_printing(): + try: + if tool_selected == self.TOOL_GATE_BYPASS: + self.select_bypass() + elif tool_selected != self.TOOL_GATE_UNKNOWN: + if filament_pos == self.FILAMENT_POS_LOADED: + self.log_info("Restoring tool loaded prior to checking gates") + + # Perform full load sequence including parking + self._note_toolchange("> %s" % self.selected_tool_string(tool=tool_selected)) + self.last_statistics = {} + self._save_toolhead_position_and_park('load') + self._select_and_load_tool(tool_selected, purge=self.PURGE_NONE) + self._persist_gate_statistics() + self._continue_after('load') + else: + self.select_tool(tool_selected) + except MmuError as ee: + raise MmuError("Failure re-selecting Tool %d:\n%s" % (tool_selected, str(ee))) + else: + # At least restore the selected tool, but don't re-load filament + self.select_tool(tool_selected) + + if not quiet: + self.log_info(self._mmu_visual_to_string()) + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_PRELOAD_help = "Preloads filament at specified or current gate" + def cmd_MMU_PRELOAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_printing(): return + if self.check_if_not_homed(): return + + gate = gcmd.get_int('GATE', self.gate_selected, minval=0, maxval=self.num_gates - 1) + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[gate]): return + + can_crossload = ( + (self.mmu_machine.can_crossload or self.mmu_machine.multigear) + and self.sensor_manager.has_gate_sensor(self.SENSOR_GEAR_PREFIX, gate) + ) + if not can_crossload: + if self.check_if_bypass(): return + if self.check_if_loaded(): return + + self.log_always("Preloading filament in %s..." % ("current gate" if gate == self.gate_selected else "gate %d" % gate)) + try: + with self.wrap_sync_gear_to_extruder(): + with self.wrap_suppress_visual_log(): + with self.wrap_action(self.ACTION_CHECKING): + + current_gate = self.gate_selected + if gate != current_gate: + self.select_gate(gate) + + try: + self._preload_gate() + + finally: + if self.gate_selected != current_gate: + # If necessary or easy restore previous gate + if self.is_in_print() or self.mmu_machine.multigear or self.filament_pos != self.FILAMENT_POS_UNLOADED: + self.select_gate(current_gate) + else: + # Lazy movement means we have side effect of changed tool/gate + self._ensure_ttg_match() + self._initialize_encoder() # Encoder 0000 + except MmuError as ee: + self.handle_mmu_error("Filament preload for gate %d failed: %s" % (gate, str(ee))) + + +def load_config(config): + return Mmu(config) diff --git a/extras/mmu/mmu_calibration_manager.py b/extras/mmu/mmu_calibration_manager.py new file mode 100644 index 000000000000..200f3dc3bdd0 --- /dev/null +++ b/extras/mmu/mmu_calibration_manager.py @@ -0,0 +1,560 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager class to handle all aspects of MMU calibration and autotuning. In +# paricular manage persistence of bowden lengths and gear rotation distances. +# +# Implements commands: +# MMU_SET_LED +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, math + +# Happy Hare imports + +# MMU subcomponent clases +from .mmu_shared import * + + +class MmuCalibrationManager: + + def __init__(self, mmu): + self.mmu = mmu + + + # -------------------- Bowden length manipulation -------------------- + # Notes: + # - The bowden length is the distance between the current choice of endstops. + # If those endstops change the bowden length must be adjusted + # - A calibrated bowden length must also be updated if the rotation_distance for + # that gate is updated + # - Testing has shown that the encoder based clog detection length is generally + # proportional to the bowden length + + # Returns the currently calibrated bowden length or the default for gate 0 if not + def get_bowden_length(self, gate=None): + if gate == None: gate = self.mmu.gate_selected + + ref_gate = gate if gate >= 0 and self.mmu.mmu_machine.variable_bowden_lengths else 0 + return self.mmu.bowden_lengths[ref_gate] + + + # Update bowden calibration for current gate and clog_detection if not yet calibrated + def update_bowden_length(self, length, gate=None, console_msg=False): + if gate == None: gate = self.mmu.gate_selected + if gate < 0: + self.mmu.log_debug("Assertion failure: cannot save bowden length for gate: %s" % self.mmu.selected_gate_string(gate)) + return + + all_gates = not self.mmu.mmu_machine.variable_bowden_lengths + + if length < 0: # Reset + action = "reset" + if all_gates: + self.mmu.bowden_lengths = [-1] * self.mmu.num_gates + else: + self.mmu.bowden_lengths[gate] = -1 + + else: + length = round(length, 1) + action = "saved" + if all_gates: + self.mmu.bowden_lengths = [length] * self.mmu.num_gates + else: + self.mmu.bowden_lengths[gate] = length + + msg = "Calibrated bowden length %.1fmm has been %s %s" % (length, action, ("for all gates" if all_gates else "gate %d" % gate)) + if console_msg: + self.mmu.log_always(msg) + else: + self.mmu.log_debug(msg) + + # Update calibration status + if not any(x == -1 for x in self.mmu.bowden_lengths): + self.mmu.calibration_status |= self.mmu.CALIBRATED_BOWDENS + + # Persist + self.mmu.save_variable(self.mmu.VARS_MMU_CALIB_BOWDEN_LENGTHS, self.mmu.bowden_lengths) + self.mmu.write_variables() + + + # Adjust all bowden lengths if endstops are changed (e.g. from MMU_TEST_CONFIG) + def adjust_bowden_lengths_on_homing_change(self): + current_home = self.mmu.save_variables.allVariables.get(self.mmu.VARS_MMU_CALIB_BOWDEN_HOME, None) + if self.mmu.gate_homing_endstop == current_home: + return + + adjustment = 0 + if current_home == self.mmu.SENSOR_ENCODER: + adjustment = self.mmu.gate_endstop_to_encoder + elif self.mmu.gate_homing_endstop == self.mmu.SENSOR_ENCODER: + adjustment = -self.mmu.gate_endstop_to_encoder + self.mmu.bowden_lengths = [length + adjustment if length != -1 else length for length in self.mmu.bowden_lengths] + self.mmu.log_debug("Adjusted bowden lengths by %.1f: %s because of gate_homing_endstop change" % (adjustment, self.mmu.bowden_lengths)) + + # Persist + self.mmu.save_variable(self.mmu.VARS_MMU_CALIB_BOWDEN_LENGTHS, self.mmu.bowden_lengths) + self.mmu.save_variable(self.mmu.VARS_MMU_CALIB_BOWDEN_HOME, self.mmu.gate_homing_endstop) + self.mmu.write_variables() + + + # -------------------- Encoder based runout/clog/tangle length manipulation -------------------- + + def calc_clog_detection_length(self, bowden_length): + cal_min = round((bowden_length * 2) / 100., 1) # 2% of bowden length seems to be good starting point + return max(cal_min, 8.) # Never less than 8mm + + + def update_clog_detection_length(self, cdl, force=False): + """ + Persist the calibrated encoder clog detection length and notify the encoder of change if in auto mode + If not forced then save if auto but don't update the encoder + """ + if not self.mmu.has_encoder(): return + if not cdl: return + + auto = (self.mmu.sync_feedback_manager.flowguard_encoder_mode == self.mmu.encoder_sensor.RUNOUT_AUTOMATIC) + + if auto or force: + self.mmu.save_variable(self.mmu.VARS_MMU_CALIB_CLOG_LENGTH, cdl, write=bool(force)) + + if auto and not force: + self.mmu.encoder_sensor.set_clog_detection_length(cdl) + + + # -------------------- Gear stepper rotation distance manipulation -------------------- + # Notes: + # - If the rotation distance is changed for gate with calibrated bowden length then adjust bowden length + + # Return current calibrated gear rotation_distance or sensible default + def get_gear_rd(self, gate=None): + if gate == None: gate = self.mmu.gate_selected + + if gate < 0: + rd = self.mmu.default_rotation_distance + else: + rd = self.mmu.rotation_distances[gate if gate >= 0 and self.mmu.mmu_machine.variable_rotation_distances else 0] + + if rd <= 0: + rd = self.mmu.default_rotation_distance + self.mmu.log_debug("Gate %d not calibrated, falling back to default rotation_distance: %.4f" % (gate, rd)) + + return rd + + + # Save rotation_distance for gate (and associated gates) adjusting any calibrated bowden length + def update_gear_rd(self, rd, gate=None, console_msg=False): + if gate == None: gate = self.mmu.gate_selected + if gate < 0: + self.mmu.log_debug("Assertion failure: cannot save gear rotation_distance for gate: %d" % gate) + return + + # Initial calibration on gate 0 also sets all gates as auto calibration starting point + all_gates = ( + not self.mmu.mmu_machine.variable_rotation_distances + or (gate == 0 and self.mmu.rotation_distances[0] == 0.) + ) + + if rd < 0: + if all_gates: + self.mmu.rotation_distances = [-1] * self.mmu.num_gates + else: + self.mmu.rotation_distances[gate] = -1 + + self.mmu.log_always("Gear rotation distance calibration has been reset for %s" % ("all gates" if all_gates else "gate %d" % gate)) + + else: + prev_rd = self.get_gear_rd(gate) + rd = round(rd, 4) + + if all_gates: + self.mmu.rotation_distances = [rd] * self.mmu.num_gates + updated_gates = list(range(self.mmu.num_gates)) + else: + self.mmu.rotation_distances[gate] = rd + updated_gates = [gate] + + # Adjust calibrated bowden lengths + for g in updated_gates if self.mmu.mmu_machine.variable_bowden_lengths else [gate]: + prev_bowden = self.mmu.bowden_lengths[g] # Must get raw value + if prev_bowden > 0: # Is calibrated + new_bl = prev_bowden * (prev_rd / rd) # Adjust for same effective calibrated distance + self.update_bowden_length(new_bl, g) + + msg = "Rotation distance calibration (%.4f) has been saved for %s" % (rd, ("all gates" if all_gates else "gate %d" % gate)) + if console_msg: + self.mmu.log_always(msg) + else: + self.mmu.log_debug(msg) + + # Update calibration status + if self.mmu.rotation_distances[0] != -1: + self.mmu.calibration_status |= self.mmu.CALIBRATED_GEAR_0 + if not any(x == -1 for x in self.mmu.rotation_distances): + self.mmu.calibration_status |= self.mmu.CALIBRATED_GEAR_RDS + + # Persist + self.mmu.save_variable(self.mmu.VARS_MMU_GEAR_ROTATION_DISTANCES, self.mmu.rotation_distances, write=True) + + + # + # Calibration implementations... + # + + # Bowden calibration - Method 1 + # This method of bowden calibration is done in reverse and is a fallback. The user inserts filament to the + # actual extruder and we measure the distance necessary to home to the defined gate homing position + def calibrate_bowden_length_manual(self, approx_bowden_length): + try: + self.mmu.log_always("Calibrating bowden length on gate %d (manual method) using %s as gate reference point" % (self.mmu.gate_selected, self.mmu._gate_homing_string())) + self.mmu._set_filament_direction(self.mmu.DIRECTION_UNLOAD) + self.mmu.selector.filament_drive() + self.mmu.log_always("Finding %s endstop position..." % self.mmu.gate_homing_endstop) + homed = False + + if self.mmu.gate_homing_endstop == self.mmu.SENSOR_ENCODER: + with self.mmu._require_encoder(): + success = self.mmu._reverse_home_to_encoder(approx_bowden_length) + if success: + actual,_,_ = success + homed = True + + else: # Gate sensor... SENSOR_GATE is shared, but SENSOR_GEAR_PREFIX is specific + actual, homed, measured, _ = self.mmu.trace_filament_move( + "Reverse homing off gate sensor", + -approx_bowden_length, + motor="gear", + homing_move=-1, + endstop_name=self.mmu.gate_homing_endstop, + ) + + if not homed: + raise MmuError("Did not home to gate sensor after moving %.1fmm" % approx_bowden_length) + + actual = abs(actual) + self.mmu.log_always("Filament homed back to gate after %.1fmm movement" % actual) + self.mmu._unload_gate() + return actual + + except MmuError as ee: + raise MmuError("Calibration of bowden length on gate %d failed. Aborting because:\n%s" % (self.mmu.gate_selected, str(ee))) + + + # Bowden calibration - Method 2 + # Automatic one-shot homing calibration from gate to endstop + # bowden_length = actual_moved + toolhead_entry_to_extruder + def calibrate_bowden_length_sensor(self, extruder_homing_max): + try: + self.mmu.log_always( + "Calibrating bowden length for gate %d using %s as gate reference point and %s as extruder homing point" % + ( + self.mmu.gate_selected, + self.mmu._gate_homing_string(), + self.mmu.extruder_homing_endstop + ) + ) + self.mmu._initialize_filament_position(dwell=True) + overshoot = self.mmu._load_gate(allow_retry=False) + + if self.mmu.extruder_homing_endstop in [self.mmu.SENSOR_EXTRUDER_ENTRY, self.mmu.SENSOR_COMPRESSION]: + if self.mmu.sensor_manager.check_sensor(self.mmu.extruder_homing_endstop): + raise MmuError("The %s sensor triggered before homing. Check filament and sensor operation" % self.mmu.extruder_homing_endstop) + + actual, extra = self.mmu._home_to_extruder(extruder_homing_max) + measured = self.mmu.get_encoder_distance(dwell=True) + self.mmu._get_encoder_dead_space() + calibrated_length = round(overshoot + actual + extra, 1) + + msg = "Filament homed to extruder after %.1fmm movement" % actual + if self.mmu.has_encoder(): + msg += "\n(encoder measured %.1fmm)" % (measured - self.mmu.gate_parking_distance) + self.mmu.log_always(msg) + + self.mmu._unload_bowden(calibrated_length) # Fast move + self.mmu._unload_gate() + return calibrated_length + + except MmuError as ee: + raise MmuError("Calibration of bowden length on gate %d failed. Aborting because:\n%s" % (self.mmu.gate_selected, str(ee))) + + + # Bowden calibration - Method 3 + # Automatic calibration from gate to extruder entry sensor or collision with extruder gear (requires encoder) + # Allows for repeats to average restult which is essential with encoder collision detection + def calibrate_bowden_length_collision(self, approximate_length, extruder_homing_max, repeats): + orig_endstop = self.mmu.extruder_homing_endstop + try: + # Can't allow "none" endstop during calibration so temporarily change it + self.mmu.extruder_homing_endstop = self.mmu.SENSOR_EXTRUDER_COLLISION + + self.mmu.log_always("Calibrating bowden length on gate %d using %s as gate reference point and encoder collision detection" % (self.mmu.gate_selected, self.mmu._gate_homing_string())) + reference_sum = spring_max = 0. + successes = 0 + + for i in range(repeats): + self.mmu._initialize_filament_position(dwell=True) + overshoot = self.mmu._load_gate(allow_retry=False) + self.mmu._load_bowden(approximate_length, start_pos=overshoot) # Get close to extruder homing point + + self.mmu.log_info("Finding extruder gear position (try #%d of %d)..." % (i+1, repeats)) + _,_ = self.mmu._home_to_extruder(extruder_homing_max) + actual = self.mmu._get_filament_position() - self.mmu.gate_parking_distance + measured = self.mmu.get_encoder_distance(dwell=True) + self.mmu._get_encoder_dead_space() + spring = self.mmu.selector.filament_release(measure=True) if self.mmu.has_encoder() else 0. + reference = actual - spring + + # When homing using collision, we expect the filament to spring back. + if spring != 0: + msg = "Pass #%d: Filament homed to extruder after %.1fmm movement" % (i+1, actual) + if self.mmu.has_encoder(): + msg += "\n(encoder measured %.1fmm, filament sprung back %.1fmm)" % (measured - self.mmu.gate_parking_distance, spring) + self.mmu.log_always(msg) + reference_sum += reference + spring_max = max(spring, spring_max) + successes += 1 + else: + # No spring means we haven't reliably homed + self.mmu.log_always("Failed to detect a reliable home position on this attempt") + + self.mmu._initialize_filament_position(True) + self.mmu._unload_bowden(reference) + self.mmu._unload_gate() + + if successes == 0: + raise MmuError("All %d attempts at homing failed. MMU needs some adjustments!" % repeats) + + return (reference_sum / successes) + + except MmuError as ee: + # Add some more context to the error and re-raise + raise MmuError("Calibration of bowden length on gate %d failed. Aborting because:\n%s" % (self.mmu.gate_selected, str(ee))) + finally: + self.mmu.extruder_homing_endstop = orig_endstop + + + def calibrate_encoder(self, length, repeats, speed, min_speed, max_speed, accel, save=True): + pos_values, neg_values = [], [] + self.mmu.log_always("%s over %.1fmm..." % ("Calibrating" if save else "Validating calibration", length)) + speed_incr = (max_speed - min_speed) / repeats + test_speed = min_speed + mean = 0 + + try: + for x in range(repeats): + if speed_incr > 0.: + self.mmu.log_always("Test run #%d, Speed=%.1f mm/s" % (x, test_speed)) + + # Move forward + self.mmu._initialize_filament_position(dwell=True) + self.mmu.trace_filament_move(None, length, speed=test_speed, accel=accel, wait=True) + counts = self.mmu._get_encoder_counts(dwell=True) + pos_values.append(counts) + self.mmu.log_always("%s+ counts: %d" % (UI_SPACE*2, counts)) + + # Move backward + self.mmu._initialize_filament_position(dwell=True) + self.mmu.trace_filament_move(None, -length, speed=test_speed, accel=accel, wait=True) + counts = self.mmu._get_encoder_counts(dwell=True) + neg_values.append(counts) + self.mmu.log_always("%s- counts: %d" % (UI_SPACE*2, counts)) + + if counts == 0: break + test_speed += speed_incr + + mean_pos = self.mmu._sample_stats(pos_values)['mean'] + mean_neg = self.mmu._sample_stats(neg_values)['mean'] + mean = (float(mean_pos) + float(mean_neg)) / 2 + + if mean == 0: + self.mmu.log_always("No counts measured. Ensure a tool was selected with filament gripped before running calibration and that your encoder is working properly") + return + + resolution = length / mean + old_result = mean * self.mmu.encoder_sensor.get_resolution() + + msg = "Load direction: mean=%(mean).2f stdev=%(stdev).2f min=%(min)d max=%(max)d range=%(range)d" % self.mmu._sample_stats(pos_values) + msg += "\nUnload direction: mean=%(mean).2f stdev=%(stdev).2f min=%(min)d max=%(max)d range=%(range)d" % self.mmu._sample_stats(neg_values) + self.mmu.log_always(msg) + + # Sanity check to ensure all teeth are reflecting / being counted. 20% tolerance + if (abs(resolution - self.mmu.encoder_sensor.get_resolution()) / self.mmu.encoder_sensor.get_resolution()) > 0.2: + self.mmu.log_warning("Warning: Encoder is not detecting the expected number of counts based on CAD parameters which may indicate an issue") + + msg = "Before calibration measured length: %.2fmm" % old_result + msg += "\nCalculated resolution of the encoder: %.4f (currently: %.4f)" % (resolution, self.mmu.encoder_sensor.get_resolution()) + self.mmu.log_always(msg) + + if save: + self.mmu.encoder_sensor.set_resolution(resolution) + self.mmu.save_variable(self.mmu.VARS_MMU_ENCODER_RESOLUTION, round(resolution, 4), write=True) + self.mmu.log_always("Encoder calibration has been saved") + self.mmu.calibration_status |= self.mmu.CALIBRATED_ENCODER + + except MmuError as ee: + # Add some more context to the error and re-raise + raise MmuError("Calibration of encoder failed. Aborting, because:\n%s" % str(ee)) + + finally: + if mean == 0: + self.mmu._set_filament_pos_state(self.mmu.FILAMENT_POS_UNKNOWN) + + + # Automatically calibrate the rotation_distance for gate>0 using encoder measurements and gate 0 as reference + # Gate 0 is always calibrated with MMU_CALILBRATE_GEAR + def calibrate_gate(self, gate, length, repeats, save=True): + try: + pos_values, neg_values = [], [] + self.mmu.select_gate(gate) + self.mmu._load_gate(allow_retry=False) + self.mmu.log_always("%s gate %d over %.1fmm..." % ("Calibrating" if (gate > 0 and save) else "Validating calibration of", gate, length)) + + if gate == 0: + self.mmu.log_always("Gate 0 is calibrated with MMU_CALIBRATE_GEAR and manual measurement, so this will run as a validation that encoder is calibrated correctly") + + for _ in range(repeats): + self.mmu._initialize_filament_position(dwell=True) + _,_,measured,delta = self.mmu.trace_filament_move("Calibration load movement", length, encoder_dwell=True) + pos_values.append(measured) + self.mmu.log_always("%s+ measured: %.1fmm (counts: %d)" % (UI_SPACE*2, (length - delta), self.mmu._get_encoder_counts(dwell=None))) + self.mmu._initialize_filament_position(dwell=True) + _,_,measured,delta = self.mmu.trace_filament_move("Calibration unload movement", -length, encoder_dwell=True) + neg_values.append(measured) + self.mmu.log_always("%s- measured: %.1fmm (counts: %d)" % (UI_SPACE*2, (length - delta), self.mmu._get_encoder_counts(dwell=None))) + + msg = "Load direction: mean=%(mean).2f stdev=%(stdev).2f min=%(min).2f max=%(max).2f range=%(range).2f" % self.mmu._sample_stats(pos_values) + msg += "\nUnload direction: mean=%(mean).2f stdev=%(stdev).2f min=%(min).2f max=%(max).2f range=%(range).2f" % self.mmu._sample_stats(neg_values) + self.mmu.log_always(msg) + + mean_pos = self.mmu._sample_stats(pos_values)['mean'] + mean_neg = self.mmu._sample_stats(neg_values)['mean'] + mean = (float(mean_pos) + float(mean_neg)) / 2 + ratio = mean / length + current_rd = self.mmu.gear_rail.steppers[0].get_rotation_distance()[0] + new_rd = round(ratio * current_rd, 4) + + self.mmu.log_always("Calibration move of %d x %.1fmm, average encoder measurement: %.1fmm - Ratio is %.4f" % (repeats * 2, length, mean, ratio)) + self.mmu.log_always("Calculated gate %d rotation_distance: %.4f (currently: %.4f)" % (gate, new_rd, self.mmu.rotation_distances[gate])) + if gate != 0: # Gate 0 is not calibrated, it is the reference and set with MMU_CALIBRATE_GEAR + gate0_rd = self.mmu.rotation_distances[0] + tolerance_range = (gate0_rd - gate0_rd * 0.2, gate0_rd + gate0_rd * 0.2) # Allow 20% variation from gate 0 + if tolerance_range[0] <= new_rd < tolerance_range[1]: + if save: + self.mmu.set_gear_rotation_distance(new_rd) + self.update_gear_rd(new_rd, console_msg=True) + else: + self.mmu.log_always("Calibration ignored because it is not considered valid (>20% difference from gate 0)") + self.mmu._unload_gate() + self.mmu._set_filament_pos_state(self.mmu.FILAMENT_POS_UNLOADED) + except MmuError as ee: + # Add some more context to the error and re-raise + raise MmuError("Calibration for gate %d failed. Aborting, because:\n%s" % (gate, str(ee))) + + + def note_load_telemetry(self, bowden_move_ratio, homing_movement, deficit): + if homing_movement is not None: + homing_movement -= deficit + self._autotune(self.mmu.DIRECTION_LOAD, bowden_move_ratio, homing_movement) + + + def note_unload_telemetry(self, bowden_move_ratio, homing_movement, deficit): + if homing_movement is not None: + homing_movement -= deficit + self._autotune(self.mmu.DIRECTION_UNLOAD, bowden_move_ratio, homing_movement) + + + # Use data from load or unload operation to auto-calibrate / auto-tune + # + # Data we can use: + # - ratio of large bowden move to that measured by encoder (0 if it can't be relied on) + # - the amount of unexpected homing necessary to reach endstop. We want some homing + # movement but we can use excessive numbers for tuning (None indicates not available) + # - the direction of filament movement + # + # Things we could possibly tune from this infomation: + # - If gate 0, use the bowden move ratio to update encoder calibration ("encoder calibration"). Not reliable so not currently done! + # - If gate 0, use excess homing move to tune the calibrated bowden length ("bowden calibration") + # but only do this if bowden move ratio is reasonable. Can be done in both directions + # - If gate >0, use the bowden move ratio to set/tune the gear rotation_distance ("gate calibration") + # but only do this if homing movement data tells us we haven't overshot. Can be done in both directions + # + # Calibration replaces the previous value. Autotuning applies a moving average + def _autotune(self, direction, bowden_move_ratio, homing_movement): + msg = "Autotune: bowden move ratio: %.4f, Extra homing movement: %s" % (bowden_move_ratio, "n/a" if homing_movement is None else "%.1fmm" % homing_movement) + if homing_movement is not None: + + # If sync-feedback is available it provides a better way to autotune rotation distance. This is retained for legacy cases + has_tension, has_compression, has_proportional = self.mmu.sync_feedback_manager.get_active_sensors() + + # Encoder based automatic calibration of gate's gear rotation_distance + # TODO Currently only works with gate >0. Could work with gate 0 if variable_rotation_distance is True + # TODO and bowden is calibrated and we don't tune bowden below + if ( + False and # TODO Temporarily disabled based on user's feedback until tested further + not any([has_tension, has_compression, has_proportional]) and + self.mmu.autotune_rotation_distance and + self.mmu.mmu_machine.variable_rotation_distances and + self.mmu.gate_selected > 0 and + bowden_move_ratio > 0 and + homing_movement > 0 + ): + if direction in [self.mmu.DIRECTION_LOAD, self.mmu.DIRECTION_UNLOAD]: + current_rd = self.mmu.gear_rail.steppers[0].get_rotation_distance()[0] + new_rd = round(bowden_move_ratio * current_rd, 4) + gate0_rd = self.mmu.rotation_distances[0] + + # Allow max 10% variation from gate 0 for autotune + if math.isclose(new_rd, gate0_rd, rel_tol=0.1): + if not self.mmu.calibrating and self.mmu.rotation_distances[self.mmu.gate_selected] > 0: + # Tuning existing calibration + new_rd = round((self.mmu.rotation_distances[self.mmu.gate_selected] * 5 + new_rd) / 6, 4) # Moving average + msg += ". Autotuned rotation_distance: %.4f for gate %d" % (new_rd, self.mmu.gate_selected) + if not math.isclose(current_rd, new_rd): + _ = self.mmu.update_gear_rd(new_rd, self.mmu.gate_selected) + else: + msg += ". Calculated rotation_distance: %.4f for gate %d failed sanity check and has been ignored" % (new_rd, self.mmu.gate_selected) + + + # Automatic calibration of bowden length based on actual homing movement telemetry + # TODO Currently only works with gate 0. Could work with other gates if variable_bowden_lengths is True and rotation distance is calibrated + if ( + self.mmu.autotune_bowden_length and + self.mmu.mmu_machine.require_bowden_move and + self.mmu.gate_selected == 0 and + ( + 0.9 < bowden_move_ratio < 1.1 or + not self.mmu.has_encoder() + ) + ): + if direction in [self.mmu.DIRECTION_LOAD, self.mmu.DIRECTION_UNLOAD]: + bowden_length = self.get_bowden_length() + # We expect homing_movement to be 0 if perfectly calibrated and perfect movement + # Note that we only change calibrated bowden length if extra homing is >1% of bowden length + error_tolerance = bowden_length * 0.01 # 1% of bowden length + if abs(homing_movement) > error_tolerance: + if homing_movement > 0: + new_bl = bowden_length + error_tolerance + else: + new_bl = bowden_length - error_tolerance + else: + new_bl = bowden_length + new_bl = round((bowden_length * 5 + new_bl) / 6, 1) # Still perform moving average to smooth changes + if not math.isclose(bowden_length, new_bl): + self.update_bowden_length(new_bl) + msg += " Autotuned bowden length: %.1f" % new_bl + + if self.mmu.gate_selected == 0 and homing_movement > 0 and bowden_move_ratio > 0: + # Bowden movement based warning of encoder calibration aka MMU_CALIBRATE_ENCODER + if not 0.95 < bowden_move_ratio < 1.05: + msg += ". Encoder measurement on gate 0 was outside of desired calibration range. You may want to check function or recalibrate" + else: + msg += ". Tuning not possible" + + self.mmu.log_debug(msg) + diff --git a/extras/mmu/mmu_environment_manager.py b/extras/mmu/mmu_environment_manager.py new file mode 100644 index 000000000000..66690eea865c --- /dev/null +++ b/extras/mmu/mmu_environment_manager.py @@ -0,0 +1,1158 @@ +# -*- coding: utf-8 -*- +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager class to implement MMU heater control and basic filament drying functionality +# +# Two setups are supported: +# 1. The more normal shared enclosure with single heater and environment sensor. In this case +# 'filament_heater' and 'environment_sensor' properties should be set. Direct heater or +# drying lifecycle control is possible. An optional venting macro will periodically be called +# with no arguments. +# 2. Where each MMU gate has a separate heater/environment sensor (e.g. EMU design). Here it +# is possible to supplied which gates to dry. The list of heaters and environment sensors +# should be set with the 'filament_heaters' and 'environment_sensors' properties. +# Further, in this mode a basic "power management" is implemented which limits the number +# of simulateous heaters to that defined by the 'max_concurrent_heaters' property. +# Individual control of per-gate heaters and lifecycle is possible by specifying gates of +# interest. The periodic venting macro will be called with a GATE parameter listing the +# currently heated gates. +# The manager will support automatic spool rotation if equiped with eSpooler and the dry cycle +# is initiated with this option. IMPORTANT: filament must be removed from the MMU inlet and +# fastened to the spool. Also, the GATES parameter must be supplied. +# +# TODO For HHv4 this needs to operate per unit (gate range) +# +# Implements commands: +# MMU_HEATER +# +# Implements printer variables: +# drying_state [{string} : list indexed by gate with values: +# DRYING_STATE_ACTIVE 'active' actively drying +# DRYING_STATE_QUEUED 'queued' waiting to start +# DRYING_STATE_COMPLETE 'complete' completed drying +# DRYING_STATE_CANCELLED 'cancelled' cycle was canceled prematurely +# DRYING_STATUS_NONE '' not part of the current cycle +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import ast, logging + +# Happy Hare imports +from ..mmu_machine import VENDOR_VVD + +# MMU subcomponent clases +from .mmu_shared import * + + +class MmuEnvironmentManager: + + CHECK_INTERVAL = 30 # How often to check heater and environment sensors (seconds) + + # Environment sensor chips with humidity + ENV_SENSOR_CHIPS = ["bme280", "htu21d", "sht3x", "lm75", "aht10"] + + # Drying states (mostly relevant for per-gate heaters) + DRYING_STATE_NONE = '' + DRYING_STATE_QUEUED = 'queued' + DRYING_STATE_ACTIVE = 'active' + DRYING_STATE_COMPLETE = 'complete' + DRYING_STATE_CANCELLED = 'canceled' + + def __init__(self, mmu): + self.mmu = mmu + self.mmu.managers.append(self) + + # Process config + self.heater_max_temp = self.mmu.config.getfloat('heater_max_temp', 65, above=0.) # Never to exceed temp to avoid melting enclosure + self.heater_default_dry_temp = self.mmu.config.getfloat('heater_default_dry_temp', 45, above=0.) + self.heater_default_dry_time = self.mmu.config.getfloat('heater_default_dry_time', 300, above=0.) + self.heater_default_humidity = self.mmu.config.getfloat('heater_default_humidity', 10, above=0.) + self.heater_vent_macro = self.mmu.config.get( 'heater_vent_macro', '') + self.heater_vent_interval = self.mmu.config.getfloat('heater_vent_interval', 0, minval=0) + self.heater_rotate_interval = self.mmu.config.getfloat('heater_rotate_interval', 5, minval=1) + + # Build tuples of drying temp / drying time indexed by filament type + drying_data_str = self.mmu.config.get('drying_data', {}) + try: + drying_data = ast.literal_eval(drying_data_str) + # Store as upper case keys (If there are duplicate keys differing only by case, the last one wins) + self.drying_data = dict((str(k).upper(), v) for k, v in drying_data.items()) + except Exception as e: + raise self.mmu.config.error("Unparsable 'drying_data' parameter: %s" % str(e)) + + # Listen of important mmu events + self.mmu.printer.register_event_handler("mmu:enabled", self._handle_mmu_enabled) + self.mmu.printer.register_event_handler("mmu:disabled", self._handle_mmu_disabled) + self.mmu.printer.register_event_handler("mmu:espooler_burst_done", self._handle_espooler_burst_done) + + # Register GCODE commands --------------------------------------------------------------------------- + self.mmu.gcode.register_command('MMU_HEATER', self.cmd_MMU_HEATER, desc=self.cmd_MMU_HEATER_help) + + self._periodic_timer = self.mmu.reactor.register_timer(self._check_mmu_environment) + self.reinit() + + + # + # Standard mmu manager hooks... + # + + def reinit(self): + self._drying_temp = None + self._drying_humidity_target = None + self._drying_start_time = self._drying_end_time = None + self._drying_gates = [] + self._drying_vent_interval = None + + # Per-gate drying state (multi-heater mode) + # gate -> dict(state, start_time, end_time, temp, humidity_target, done_reason, last_temp, last_humidity) + self._gate_drying = {} # Contains details required for managing drying for scheduled gates + + # Drying state indexed by gate + self._drying_state = [self.DRYING_STATE_NONE] * self.mmu.num_gates + + self._drying_queue = [] # Queued gates awaiting heater capacity (FIFO) + self._vent_timer = None + + # Optional auto spool rotation (eSpooler) + self._rotate_timer = None + self._rotate_enabled = False + self.spools_to_rotate = [] # Queue of spools that we are rotating (one at a time) + + + # Module has no ready/connect/disconnect lifecycle hooks + + + def set_test_config(self, gcmd): + if self.has_heater(): + self.heater_default_dry_temp = gcmd.get_float('HEATER_DEFAULT_DRY_TEMP', self.heater_default_dry_temp, above=0.) + self.heater_default_dry_time = gcmd.get_float('HEATER_DEFAULT_DRY_TIME', self.heater_default_dry_time, above=0.) + self.heater_default_humidity = gcmd.get_float('HEATER_DEFAULT_HUMIDITY', self.heater_default_humidity, above=0.) + self.heater_vent_macro = gcmd.get( 'HEATER_VENT_MACRO', self.heater_vent_macro) + self.heater_vent_interval = gcmd.get_float('HEATER_VENT_INTERVAL', self.heater_vent_interval, minval=0) + self.heater_rotate_interval = gcmd.get_float('HEATER_ROTATE_INTERVAL', self.heater_rotate_interval, minval=0) + + + def get_test_config(self): + msg = "" + if self.has_heater(): + msg = "\n\nHEATER:" + msg += "\nheater_default_dry_temp = %.1f" % self.heater_default_dry_temp + msg += "\nheater_default_dry_time = %.1f" % self.heater_default_dry_time + msg += "\nheater_default_humidity = %.1f" % self.heater_default_humidity + msg += "\nheater_vent_macro = %s" % self.heater_vent_macro + msg += "\nheater_vent_interval = %.1f" % self.heater_vent_interval + msg += "\nheater_rotate_interval = %.1f" % self.heater_rotate_interval + + return msg + + + def check_test_config(self, param): + return vars(self).get(param) is None + + # + # Mmu Heater manager public access... + # + + def is_drying(self): + """ + Returns whether the MMU heater is currently in drying cycle + """ + for s in self._drying_state: + if s == self.DRYING_STATE_ACTIVE or s == self.DRYING_STATE_QUEUED: + return True + return False + + + def _has_per_gate_heaters(self): + """ + Returns whether this MMU configuration has a separate heater for each gate + (and corresponding environment sensor per gate) + """ + heaters = self.mmu.mmu_machine.filament_heaters + sensors = self.mmu.mmu_machine.environment_sensors + if not heaters or not sensors: + return False + return True + + + def has_heater(self): + if self._has_per_gate_heaters(): + heaters = self.mmu.mmu_machine.filament_heaters + return bool(heaters) # At least one heater configured + return self.mmu.mmu_machine.filament_heater != '' + + + def has_env_sensor(self): + if self._has_per_gate_heaters(): + sensors = self.mmu.mmu_machine.environment_sensors + return bool(sensors) + return self.mmu.mmu_machine.environment_sensor != '' + + + def _get_active_gates(self): + """ + Return list of active gates from per-gate drying states + """ + return [i for i, s in enumerate(self._drying_state) if s == self.DRYING_STATE_ACTIVE] + + + # + # GCODE Commands ----------------------------------------------------------- + # + + cmd_MMU_HEATER_help = "Control MMU heater(s) and filament drying cycle" + cmd_MMU_HEATER_param_help = ( + "MMU_HEATER: %s\n" % cmd_MMU_HEATER_help + + "STOP = [0|1] Turn off heater and drying cycle\n" + + "DRYING_DATA = [0|1] Dump configured drying data for filament types\n" + + "DRY = [0|1] Disable/enable filament heater for filament drying cycle\n" + + "TIMER = #(mins) Force drying time\n" + + "TEMP = #(degrees) Force temperature\n" + + "HUMIDITY = % Terminate drying when humidty goal is reached\n" + + "GATES = g1,g2 Gates to control ONLY IF MMU has per-gate heaters/dryers\n" + + "ROTATE = [0|1] Rotate spool (requires eSpooler and explicit GATES)\n" + + "ROTATE_INTERVAL = #(mins) How often to rotate spools when drying (requires eSpooler)\n" + + "VENT_INTERVAL = #(mins) How often to call 'vent' macro in drying cycle\n" + + "(no parameters for status report)" + ) + def cmd_MMU_HEATER(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_HEATER_param_help), color=True) + return + + if not self.has_heater(): + raise gcmd.error("No MMU heater configured") + + drying_data = gcmd.get_int('DRYING_DATA', 0, minval=0, maxval=1) + stop = gcmd.get_int('STOP', None, minval=0, maxval=1) + dry = gcmd.get_int('DRY', None, minval=0, maxval=1) + timer = gcmd.get_float('TIMER', None, minval=0.) + temp = gcmd.get_float('TEMP', None, minval=0., maxval=self.heater_max_temp) + humidity = gcmd.get_float('HUMIDITY', self.heater_default_humidity, minval=0.) + vent_interval = gcmd.get_float('VENT_INTERVAL', self.heater_vent_interval, minval=0.) + rotate = gcmd.get_int('ROTATE', 0, minval=0, maxval=1) + rotate_interval = gcmd.get_float('ROTATE_INTERVAL', self.heater_rotate_interval, minval=1.) + + # GATE is a common user mistake so interpret as GATES of one element + gate = gcmd.get_int('GATE', None, minval=0, maxval=self.mmu.num_gates - 1) + if gate is not None: + gates_str = str(gate) + else: + gates_str = gcmd.get('GATES', "!") + + gates = [] + if gates_str != "!": + # Supplied list of gates + gates_param = True + try: + for gate in gates_str.split(','): + gate = int(gate) + if 0 <= gate < self.mmu.num_gates: + gates.append(gate) + except ValueError: + raise gcmd.error("Invalid GATES parameter: %s" % gates_str) + else: + gates_param = False + all_gates = list(range(self.mmu.num_gates)) + empty_gates = [ + i for i, status in enumerate(self.mmu.gate_status) + if status == self.mmu.GATE_EMPTY + ] + full_gates = [ + i for i, status in enumerate(self.mmu.gate_status) + if status != self.mmu.GATE_EMPTY + ] + + def _format_minutes(minutes): + hours, mins = divmod(int(minutes), 60) + parts = [] + if hours: + parts.append("%d hour%s" % (hours, "" if hours == 1 else "s")) + if mins: + parts.append("%d minute%s" % (mins, "" if mins == 1 else "s")) + if not (hours or mins): + parts.append("<1 minute") + return " ".join(parts) + + # Display drying data table --------------------------------------------- + if drying_data: + msg = u"Drying data:\n" + for material in sorted(self.drying_data.keys()): + t, minutes = self.drying_data[material] + # Avoid format() on unicode with alignment in Py2 edge-cases; keep it simple + msg += u"%s %s°C for %s\n" % (material + ":", int(t), _format_minutes(minutes)) + self.mmu.log_always(msg) + return + + # Cancel drying cycle / Heater off -------------------------------------- + if stop or temp == 0: + if self._has_per_gate_heaters(): + if not gates_param: + gates = all_gates + + if self.is_drying(): + # STOP=1 with explicit GATES=... cancels only those gates in multi-heater mode + cancelled = self._cancel_gates(gates, reason="cancelled") + if cancelled: + self.mmu.log_info("Cancelled drying for gates: %s" % ",".join(map(str, gates))) + else: + self.mmu.log_info("No matching active/queued gates to cancel") + + # If all gates are now done, stop overall cycle + all_done = True + for g in self._drying_gates: + gd = self._gate_drying.get(g) + if not gd or gd.get('state') not in [self.DRYING_STATE_COMPLETE, self.DRYING_STATE_CANCELLED]: + all_done = False + break + if all_done: + self._stop_drying_cycle("Drying cycle stopped (all selected gates cancelled)", reset_state=True) + + else: + # Always make sure heater is turned off + for gate in gates: + self._heater_off(gate=gate) + + else: + # Otherwise stop whole cycle / single heater off + if self.is_drying(): + self.mmu.log_info("Cancelled drying cycle") + self._stop_drying_cycle(reset_state=True) + + else: + # Always make sure heater is turned off + self._heater_off() + + return + + # Raw heater control ---------------------------------------------------- + if not dry and temp is not None: + if not gates_param: + gates = full_gates # Default to all non empty gates + + # In per-gate mode, apply TEMP to the selected gate heaters + if self._has_per_gate_heaters(): + if not gates: + self.mmu.log_always("No gates selected for raw heater control") + return + + if len(gates) > self.mmu.mmu_machine.max_concurrent_heaters: + self.mmu.log_error("Exceeded max concurrent heaters") + return + + # Best-effort: set each selected gate heater to TEMP + # - If gate is queued in a drying cycle: only update _gate_drying target (do not turn on yet) + # - If gate is active: update _gate_drying and apply immediately + # - If not in drying cycle OR gate not in current drying gates: apply immediately + for gate in gates: + gd = self._gate_drying.get(gate) + + if self.is_drying() and gd is not None: + state = gd.get('state') + + # Update per-gate target in all cases when part of cycle + gd['temp'] = temp + + if state == self.DRYING_STATE_QUEUED: + # Don't power on yet; it will be applied when the gate becomes active + continue + + # Active (or any unexpected state): apply immediately + self._heater_on(temp, gate=gate) + continue + + # Not in drying cycle, or gate not part of current cycle: apply immediately + self._heater_on(temp, gate=gate) + + return + + # Single heater mode + self._heater_on(temp) + if self.is_drying(): + self._drying_temp = temp + return + + # Initiate drying cycle ------------------------------------------------- + if dry: + if not self.has_env_sensor(): + self.mmu.log_warning("MMU environment sensor not found. Check 'environment_sensor' configuration") + return + + if self.is_drying(): + self.mmu.log_always("MMU already in filament drying cycle. Stop current cycle first") + return + + # Optional spool rotation (requires eSpooler and explicit gates) + # (BTT ViViD is allowed if not in print) + if rotate and not (self.mmu.has_espooler() or self.mmu.mmu_machine.mmu_vendor == VENDOR_VVD): + self.mmu.log_warning("Rotation requested but eSpooler not fitted - ignoring") + rotate = 0 + + if rotate and not gates_param: + raise gcmd.error("ROTATE requires explicit GATES parameter") + + if not rotate and not gates_param: + gates = full_gates # Default to all non empty gates + + if rotate: + for gate in gates: + if self.mmu.gate_status[gate] != self.mmu.GATE_EMPTY: + self.mmu.log_warning("Gate %d is not empty so cannot rotate (filament end must be removed from the gate and secured to the spool for rotation)" % gate) + + # Per-gate recommended temps/times, plus overall notes + per_gate_plan = self._get_drying_plan(gates) + # If TIMER specified, override all selected gates to that time (multi-heater mode uses per-gate timers) + if timer is not None and self._has_per_gate_heaters(): + for gate in gates: + per_gate_plan[gate]['timer'] = timer + + # If TEMP specified, override all selected gates to that temp (still warn if above recommendation) + if temp is not None: + for gate in gates: + if temp > per_gate_plan[gate]['temp']: + if per_gate_plan[gate]['material']: + self.mmu.log_warning(u"Warning: Gate %d drying temperature %.1f°C is greater than that recommended for %s (%.1f°C)" + % (gate, temp, per_gate_plan[gate]['material'], per_gate_plan[gate]['temp'])) + else: + self.mmu.log_warning(u"Warning: Gate %d has unknown filament type. Cannot validate temperature %.1f°C" % (gate, temp)) + per_gate_plan[gate]['temp'] = temp + else: + # Default to each filament type recommended temperature and dry time + lowest = self.heater_default_dry_temp + longest = self.heater_default_dry_time + for gate in gates: + t = per_gate_plan[gate]['temp'] + d = per_gate_plan[gate]['timer'] + if t < lowest: lowest = t + if d > longest: longest = d + + # If we only have a single heater apply the lowest temp for longest time + if not self._has_per_gate_heaters(): + temp = lowest + info = "specified" + if timer is None: + timer = longest + info = "longest" + self.mmu.log_info(u"Defaulting to lowest drying temperature of %.1f°C for %s %s given filaments types currently in MMU" + % (temp, info, _format_minutes(timer))) + + # Note that in multi-heater mode, each gate's temp and end_time is tracked independently + self._drying_time = timer or self.heater_default_dry_time + self._drying_temp = temp or self.heater_default_dry_temp + self._drying_humidity_target = humidity + self._drying_start_time = self.mmu.reactor.monotonic() + self._drying_end_time = self._drying_start_time + self._drying_time * 60 + self._drying_gates = gates + self._drying_vent_interval = vent_interval + self._drying_rotate_interval = rotate_interval + + # Optional spool rotation state + self.spools_to_rotate = [] + self._rotate_enabled = bool(rotate) + if self._rotate_enabled: + self._rotate_timer = rotate_interval * 60.0 + else: + self._rotate_timer = None + + # Initiate drying cycle + self._start_drying_cycle(per_gate_plan) + msg = u"MMU filament drying cycle started:" + + elif self.is_drying(): + msg = u"MMU is in filament drying cycle:" + + else: # Not in drying cycle, but let's check heaters + if self._has_per_gate_heaters(): + # Per-gate heaters + heaters_on = [] + heaters_off = [] + # Report all gates (0..num_gates-1) + for gate in range(self.mmu.num_gates): + cur_temp, cur_target = self._get_heater_status(gate) + if cur_target is None: + # Heater missing / not configured; skip silently + continue + + if cur_target != 0: + heaters_on.append((gate, cur_target, cur_temp)) + else: + heaters_off.append(gate) + + if heaters_on: + msg = u"Not in drying cycle but one or more gate heaters are on:" + for gate, target, actual in heaters_on: + msg += u"\nGate %d: Target temperature %.1f°C (current: %.1f°C)" % (gate, target, actual) + if heaters_off: + msg += u"\nGate heaters off: %s" % u",".join([str(g) for g in heaters_off]) + else: + msg = u"Not in drying cycle and all gate heaters are off" + + else: + # Single shared heater + cur_temp, cur_target = self._get_heater_status() + if cur_target is None: + msg = u"Heater if not found / misconfigured" + elif cur_target != 0: + msg = u"Not in drying cycle but heater is on. Target temperature: %.1f°C (current: %.1f°C)" % (cur_target, cur_temp) + else: + msg = u"Not in drying cycle and heater is off" + + # Display status report of drying cycle --------------------------------- + if self.is_drying(): + now = self.mmu.reactor.monotonic() + + if self._drying_gates: + msg += u"\nDrying filaments in gates: %s" % u",".join(str(g) for g in self._drying_gates) + + if not self._has_per_gate_heaters(): + # Single heater status report + remaining_mins = _format_minutes((self._drying_end_time - now) // 60) + cur_temp, cur_humidity = self._get_environment_status() + msg += u"\nCycle time: %s (remaining: %s)" % (_format_minutes(self._drying_time), remaining_mins) + if cur_temp is not None: + msg += u"\nTarget humidity: %.1f%%" % self._drying_humidity_target + if cur_humidity is not None: + msg += u" (current: %.1f%%)" % cur_humidity + else: + msg += u"\nEnvironment sensor not available / misconfigured" + # Use heater's temp instead + cur_temp, _ = self._get_heater_status() + if cur_temp is None: cur_temp = -1 # Saftey, should not be possible to get here + msg += u"\nDrying temp: %.1f°C (current: %.1f°C)" % (self._drying_temp, cur_temp) + + else: + # Per-gate status report + msg += u"\nPer-gate dryer mode (max concurrent heaters: %d). Humidty target %.1f%%" % (self.mmu.mmu_machine.max_concurrent_heaters, self._drying_humidity_target) + for gate in self._drying_gates: + gd = self._gate_drying.get(gate, {}) + state = gd.get('state', self.DRYING_STATE_NONE) + material = gd.get('material', None) + t = gd.get('temp', None) + last_t = gd.get('last_temp', None) + last_h = gd.get('last_humidity', None) + end_t = gd.get('end_time', None) + if end_t is not None and state == self.DRYING_STATE_ACTIVE: + rem = max(0, int((end_t - now) // 60)) + rem_txt = _format_minutes(rem) + else: + rem_txt = None + + line = u"\nGate %d: " % gate + if state == self.DRYING_STATE_ACTIVE: + if last_t is not None: + line += u"Drying %s %.1f°C (target %.1f°C)" % (material, last_t, t) + if last_h is not None: + line += u", humidity %.1f%%" % last_h + if rem_txt is not None: + line += u", %s remaining" % rem_txt + + elif state == self.DRYING_STATE_QUEUED: + line += u"(queued waiting for heater slot, target %.1f°C)" % t + + elif state in [self.DRYING_STATE_COMPLETE, self.DRYING_STATE_CANCELLED]: + reason = gd.get('done_reason', 'complete' if state == self.DRYING_STATE_COMPLETE else 'cancelled') + line += u"(%s" % reason + if last_h is not None: + line += u", final humidity: %.1f%%" % last_h + line += u")" + + msg += line + + # Venting status + if self._vent_timer is not None: + msg += u"\nVenting operational (running macro %s every %s, next in %s)" % ( + self.heater_vent_macro, + _format_minutes(self._drying_vent_interval), + _format_minutes(max(self.CHECK_INTERVAL, self._vent_timer) / 60), + ) + else: + if not self.heater_vent_macro: + vent_reason = "heater_vent_macro not set" + else: + vent_reason = "heater_vent_interval is 0" + msg += u"\nVenting not operational (%s)" % vent_reason + + # Rotation status (eSpooler) + if self._rotate_enabled: + msg += u"\nSpool rotation enabled (running every %s, next in %s)" % ( + _format_minutes(self._drying_rotate_interval), + _format_minutes(max(self.CHECK_INTERVAL, self._rotate_timer) / 60), + ) + elif self.mmu.has_espooler(): + msg += u"\nSpool rotation not enabled" + + # Report status + self.mmu.log_always(msg) + + + def get_status(self, eventtime=None): + """ + Structured status for client consumption. + We don't duplicate temperature or humidity data here but expect the client to read configuration + and look up appropriate heator and environemnt sensor objects directly + """ + status = { + 'drying_state': list(self._drying_state), + } + return status + + + # + # Internal implementation -------------------------------------------------- + # + + def _handle_mmu_disabled(self): + """ + Event indicating that the MMU unit was disabled + """ + self._stop_drying_cycle(reset_state=True) + self._heater_off() + self.spools_to_rotate = [] + + + def _handle_mmu_enabled(self): + """ + Event indicating that the MMU unit was enabled + """ + self.reinit() + + + def _check_mmu_environment(self, eventtime): + """ + Reactor callback to periodically check drying status and to rationalize state + """ + if not self.is_drying(): + return self.mmu.reactor.NEVER + + now = self.mmu.reactor.monotonic() + + # Per-gate drying mode + if self._has_per_gate_heaters(): + # Update active gates: check completion / humidity threshold + completed_any = False + for gate in list(self._get_active_gates()): + gd = self._gate_drying.get(gate) + if not gd or gd.get('state') != self.DRYING_STATE_ACTIVE: + continue + + # Read environment sensor + cur_temp, cur_humidity = self._get_environment_status(gate=gate) + gd['last_temp'] = cur_temp + gd['last_humidity'] = cur_humidity + + # Cycle complete (per gate) + if gd.get('end_time') is not None and (gd['end_time'] - now) <= 0: + self._heater_off(gate=gate) + gd['state'] = self.DRYING_STATE_COMPLETE + gd['done_reason'] = 'timer complete' + completed_any = True + try: + self._drying_state[gate] = self.DRYING_STATE_COMPLETE + except Exception: + pass + continue + + # Humidity goal reached (per gate) + if cur_humidity is not None and cur_humidity <= self._drying_humidity_target: + self._heater_off(gate=gate) + gd['state'] = self.DRYING_STATE_COMPLETE + gd['done_reason'] = 'humidity goal reached' + completed_any = True + try: + self._drying_state[gate] = self.DRYING_STATE_COMPLETE + except Exception: + pass + continue + + # If any heater slots freed, start next queued gates + if completed_any: + self._start_next_queued_gates(now) + + # If all gates done, stop overall drying cycle + all_done = True + for gate in self._drying_gates: + gd = self._gate_drying.get(gate) + if not gd or gd.get('state') not in [self.DRYING_STATE_COMPLETE, self.DRYING_STATE_CANCELLED]: + all_done = False + break + if all_done: + self._stop_drying_cycle("Drying cycle complete (all gates)", reset_state=False) + return self.mmu.reactor.NEVER + + else: # Single heater mode + + # Cycle complete? + if (self._drying_end_time - now) <= 0: + cur_temp, cur_humidity = self._get_environment_status() + for gate in range(self.mmu.num_gates): + try: + if self._drying_state[gate] == self.DRYING_STATE_ACTIVE: + self._drying_state[gate] = self.DRYING_STATE_COMPLETE + except Exception: + pass + self._stop_drying_cycle("Drying cycle complete. Final humidity: %.1f%%" % cur_humidity, reset_state=False) + return self.mmu.reactor.NEVER + + # Humidity goal reached? + cur_temp, cur_humidity = self._get_environment_status() + if cur_humidity is not None and cur_humidity <= self._drying_humidity_target: + for gate in range(self.mmu.num_gates): + try: + if self._drying_state[gate] == self.DRYING_STATE_ACTIVE: + self._drying_state[gate] = self.DRYING_STATE_COMPLETE + except Exception: + pass + self._stop_drying_cycle("Drying cycle terminated because humidity goal %.1f%% reached" % self._drying_humidity_target, reset_state=False) + return self.mmu.reactor.NEVER + + # Run periodic venting (macro) + if self._vent_timer is not None: + self._vent_timer -= self.CHECK_INTERVAL + + if self._vent_timer < 0 and self.heater_vent_macro: + cmd = self.heater_vent_macro + if self._has_per_gate_heaters(): + cmd += " GATES=%s" % ",".join(map(str, self._get_active_gates())) + self.mmu.log_debug("MmuEnvironmentManager: Running heater vent macro '%s'" % cmd) + self.mmu.wrap_gcode_command(cmd, exception=False) # Will report errors without exception + + # Reset countdown regardless (prevents hammering if undefined or failing) + self._vent_timer = self._drying_vent_interval * 60.0 if self._drying_vent_interval else None + + # Run periodic spool rotation (eSpooler) + if self._rotate_timer is not None and self._rotate_enabled: + self._rotate_timer -= self.CHECK_INTERVAL + + if self._rotate_timer < 0: + # Re-check EMPTY status at time of rotation (supports dynamic state changes) + if self._has_per_gate_heaters(): + candidates = list(self._get_active_gates()) + else: + candidates = list(range(self.mmu.num_gates)) + + gates_to_rotate = [] + for gate in candidates: + try: + if self.mmu.gate_status[gate] == self.mmu.GATE_EMPTY: + gates_to_rotate.append(gate) + except Exception: + pass + + if gates_to_rotate: + self._rotate_spools_in_gates(gates_to_rotate) + + self._rotate_timer = self._drying_rotate_interval * 60.0 # To seconds + + # Reschedule + return eventtime + self.CHECK_INTERVAL + + + def _start_drying_cycle(self, per_gate_plan=None): + if self.is_drying(): + return + + self.mmu.log_debug("MmuEnvironmentManager: Filament drying started") + + # Reset state at the beginning of a new cycle + self._drying_state = [self.DRYING_STATE_NONE] * self.mmu.num_gates + + # Vent timer countdown (seconds). 0/None disables venting. + if self._drying_vent_interval: + self._vent_timer = self._drying_vent_interval * 60.0 # To seconds + else: + self._vent_timer = None + + # Turn on heater or heaters depending on mode + if not self._has_per_gate_heaters(): + # Single heater mode + for gate in range(self.mmu.num_gates): + try: + self._drying_state[gate] = self.DRYING_STATE_ACTIVE + except Exception: + pass + self._heater_on(self._drying_temp) + + else: + # Multi heater mode: Initialize per-gate state and start as many as possible + self._gate_drying = {} + self._drying_queue = [] + self._drying_state = [self.DRYING_STATE_NONE] * self.mmu.num_gates + + if per_gate_plan is None: + per_gate_plan = self._get_drying_plan(self._drying_gates) + + # Queue all selected gates; we'll start up to max_concurrent_heaters + for gate in self._drying_gates: + plan = per_gate_plan.get(gate, {}) + gtemp = plan.get('temp', self.heater_default_dry_temp) + gtime = plan.get('timer', self.heater_default_dry_time) + material = plan.get('material', "unknown") + + self._gate_drying[gate] = { + 'state': self.DRYING_STATE_QUEUED, + 'start_time': None, + 'end_time': None, + 'material': material, + 'temp': gtemp, + 'timer': gtime, + 'humidity_target': self._drying_humidity_target, + 'done_reason': None, + 'last_temp': None, + 'last_humidity': None, + } + self._drying_queue.append(gate) + try: + self._drying_state[gate] = self.DRYING_STATE_QUEUED + except Exception: + pass + + # Turn heater on if possible else queue + self._start_next_queued_gates(self.mmu.reactor.monotonic()) + + # Enable + self.mmu.reactor.update_timer(self._periodic_timer, self.mmu.reactor.NOW) + + + def _start_next_queued_gates(self, now): + """ + Start queued gates up to max concurrent heaters. + In this setup ensure the maximum drying time is applied per gate + meaning the total drying time for all gates might be longer. + """ + max_h = self.mmu.mmu_machine.max_concurrent_heaters + if max_h <= 0: + max_h = 1 + + while len(self._get_active_gates()) < max_h and self._drying_queue: + gate = self._drying_queue.pop(0) + gd = self._gate_drying.get(gate) + if not gd or gd.get('state') != self.DRYING_STATE_QUEUED: + continue + + # Use per-gate time (from material) else user forced timer or default time + per_gate_minutes = gd.get('timer', None) + if per_gate_minutes is None: + per_gate_minutes = int(self._drying_time) + + gd['start_time'] = now + gd['end_time'] = now + (int(per_gate_minutes) * 60) + gd['state'] = self.DRYING_STATE_ACTIVE + gd['done_reason'] = None + + try: + self._drying_state[gate] = self.DRYING_STATE_ACTIVE + except Exception: + pass + + # Read environment sensor + cur_temp, cur_humidity = self._get_environment_status(gate=gate) + gd['last_temp'] = cur_temp + gd['last_humidity'] = cur_humidity + + self._heater_on(gd.get('temp'), gate=gate) + + + def _stop_drying_cycle(self, msg="Filament drying stopped", reset_state=True): + if self.is_drying() or self._drying_end_time is not None: + self.mmu.log_info(msg) + self.mmu.reactor.update_timer(self._periodic_timer, self.mmu.reactor.NEVER) + + # Turn off all heaters in either mode + if self._has_per_gate_heaters(): + for gate in list(self._get_active_gates()): + self._heater_off(gate=gate) + # Best effort: also turn off any configured heaters for selected gates + for gate in self._drying_gates: + self._heater_off(gate=gate) + else: + self._heater_off() + + self._drying_queue = [] + self._drying_gates = [] + + if reset_state: + self._drying_state = [self.DRYING_STATE_NONE] * self.mmu.num_gates + + # Stop rotation + self._rotate_timer = None + self._rotate_enabled = False + + + def _cancel_gates(self, gates, reason="cancelled"): + """ + Cancel drying for the given gates in multi-heater mode. + - If gate is active: turn off its heater, mark done, remove from active list. + - If gate is queued: remove from pending queue, mark done. + - If gate is unknown: ignore. + Returns number of gates actually cancelled. + """ + cancelled = 0 + now = self.mmu.reactor.monotonic() + + for gate in list(gates): + gd = self._gate_drying.get(gate) + + # If we don't have state for it, it might not be part of this cycle + if gd is None: + continue + + state = gd.get('state') + + if state == self.DRYING_STATE_ACTIVE: + # Turn off heater and mark done + self._heater_off(gate=gate) + gd['state'] = self.DRYING_STATE_COMPLETE + gd['done_reason'] = reason + gd['end_time'] = now + self._drying_state[gate] = self.DRYING_STATE_CANCELLED + cancelled += 1 + + elif state == self.DRYING_STATE_QUEUED: + # Remove from pending queue and mark done + try: + while gate in self._drying_queue: + self._drying_queue.remove(gate) + except Exception: + pass + gd['state'] = self.DRYING_STATE_COMPLETE + gd['done_reason'] = reason + gd['end_time'] = now + self._drying_state[gate] = self.DRYING_STATE_CANCELLED + cancelled += 1 + + elif state == self.DRYING_STATE_COMPLETE: + # Already done; no-op + pass + + # After cancellations, if we freed heater slots start next queued gates + if self._has_per_gate_heaters(): + self._start_next_queued_gates(now) + + return cancelled + + + def _heater_on(self, temp, gate=None): + """ + Turn MMU heater on. + """ + if not self._has_per_gate_heaters(): + self.mmu.log_debug(u"MmuEnvironmentManager: Heater %s set to target temp of %.1f°C" % (self.mmu.mmu_machine.filament_heater, temp)) + hname = self._heater_name(self.mmu.mmu_machine.filament_heater) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=%.1f" % (hname, temp)) + return + + heaters = self.mmu.mmu_machine.filament_heaters + if gate < 0 or gate >= len(heaters) or not heaters[gate]: + self.mmu.log_warning("MmuEnvironmentManager: No heater configured for gate %d" % gate) + return + + hname = self._heater_name(heaters[gate]) + self.mmu.log_debug(u"MmuEnvironmentManager: Gate %d heater %s set to target temp of %.1f°C" % (gate, hname, temp)) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=%.1f" % (hname, temp)) + + + def _heater_off(self, gate=None): + """ + Turn MMU heater off. If gate=None then turn off all heaters + """ + if not self._has_per_gate_heaters() and self.mmu.mmu_machine.filament_heater: + self.mmu.log_debug("MmuEnvironmentManager: Heater %s turned off" % self.mmu.mmu_machine.filament_heater) + hname = self._heater_name(self.mmu.mmu_machine.filament_heater) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=0" % hname) + return + + if gate is None: + # Turn off all known heaters (best effort) + self.mmu.log_debug("MmuEnvironmentManager: All gate heaters turned off") + heaters = self.mmu.mmu_machine.filament_heaters + for i in range(len(heaters)): + if heaters[i]: + hname = self._heater_name(heaters[i]) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=0" % hname) + return + + heaters = self.mmu.mmu_machine.filament_heaters + if gate < 0 or gate >= len(heaters) or not heaters[gate]: + return + _,target = self._get_heater_status(gate) + if target: + hname = self._heater_name(heaters[gate]) + self.mmu.log_debug("MmuEnvironmentManager: Gate %d heater %s turned off" % (gate, hname)) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=0" % hname) + + + def _heater_name(self, heater_obj_name): + """ + Return just the simple heater name from the heater object name + """ + return heater_obj_name.split(None, 1)[1].strip() + + + def _get_heater_status(self, gate=None): + """ + Return tuple of temperature and target temperature from heater + either the single heater or the per-gate heater + Returns (None, None) if heater is not configured / not found. + """ + if gate is None: + heater_name = self.mmu.mmu_machine.filament_heater + else: + heaters = self.mmu.mmu_machine.filament_heaters + if gate < 0 or gate >= len(heaters) or not heaters[gate]: + return (None, None) + heater_name = heaters[gate] + + obj = self.mmu.printer.lookup_object(heater_name, None) + if obj is None: + return (None, None) + + status = obj.get_status(0) + return (status.get('temperature'), status.get('target')) + + + def _get_environment_status(self, gate=None): + """ + Return tuple of temperature and humidity from environment sensor. + Note that some configured sensors may only offer temperature + """ + if gate is None: + sensor = self.mmu.mmu_machine.environment_sensor + else: + sensors = self.mmu.mmu_machine.environment_sensors + if gate < 0 or gate >= len(sensors) or not sensors[gate]: + return None, None + sensor = sensors[gate] + + obj = self.mmu.printer.lookup_object(sensor, None) + if obj is None: + return None, None + + status = obj.get_status(0) + temperature = status.get('temperature') + + # See if chip supports humidity (we hope so) + humidity = None + p = sensor.split() + s_name = p[1] if len(p) > 1 else None + if s_name: + for chip in self.ENV_SENSOR_CHIPS: + obj = self.mmu.printer.lookup_object("%s %s" % (chip, s_name), None) + if obj: + humidity = obj.get_status(0).get('humidity') + break + + return (temperature, humidity) + + + def _rotate_spools_in_gates(self, gates): + """ + eSpooler-driven spool rotation. + Move the spools in the retract direction a small distance, 90 degrees is perfect + """ + self.mmu.log_info("Rotating spools in gates: %s..." % ",".join(map(str, gates))) + if self.mmu.mmu_machine.mmu_vendor != VENDOR_VVD: + self.spools_to_rotate = list(gates) + # Initiate rotation of first spool -- they are moved in sequence for asetics and to avoid possiblity of overload + self._rotate_spool(self.spools_to_rotate[0]) + return + + # Special case VVD design because of unique spool rotation using shared gear stepper coupled to gate selection + if not self.mmu.is_in_print(): + gate_selected = self.mmu.gate_selected + for gate in gates: + self.mmu.select_gate(gate) + _,_,_,_ = self.mmu.trace_filament_move("Rotating spool for drying", -100, motor="gear", wait=True) + self.mmu.select_gate(gate_selected) + + + def _rotate_spool(self, gate): + """ + Send event to cause a small rewind action to rotate the spool in gate + """ + power = self.mmu.espooler_rewind_burst_power + duration = self.mmu.espooler_rewind_burst_duration + self.mmu.printer.send_event("mmu:espooler_burst", gate, power / 100., duration, self.mmu.ESPOOLER_REWIND) + + + def _handle_espooler_burst_done(self, gate): + """ + Event indicating that a spool rotation completed + """ + if gate in self.spools_to_rotate: + self.spools_to_rotate.remove(gate) + if self.spools_to_rotate: + self._rotate_spool(self.spools_to_rotate[0]) + + + def _get_max_drying_temp_time(self, gates): + """ + For the given gates, look up each gate's material to find drying data (temp/time) + Return (lowest_temp, longest_time) across the set. + + If a material is not found in self.drying_data, use: + - self.heater_default_dry_temp + - self.heater_default_dry_time + """ + default_temp = self.heater_default_dry_temp + default_time = self.heater_default_dry_time + + lowest_temp = None + longest_time = None + + for gate in gates: + material = self.mmu.gate_material[gate] + key = str(material).upper() + + temp, duration = self.drying_data.get(key, (default_temp, default_time)) + + # Track lowest temperature + if lowest_temp is None or temp < lowest_temp: + lowest_temp = temp + + # Track longest time + if longest_time is None or duration > longest_time: + longest_time = duration + + # If no matching materials return defaults + if lowest_temp is None: + lowest_temp = default_temp + if longest_time is None: + longest_time = default_time + + return (lowest_temp, longest_time) + + + def _get_drying_plan(self, gates): + """ + For the given gates, look up each gate's material to find drying data (temp/time). + Returns dict indexed by gate: + plan[gate] = { 'temp': recommended_temp, 'timer': recommended_time, ... } + + If a material is not found in self.drying_data, use defaults. + """ + max_temp = self.heater_max_temp + default_temp = self.heater_default_dry_temp + default_time = self.heater_default_dry_time + + plan = {} + for gate in gates: + material = self.mmu.gate_material[gate] + key = str(material).upper() + temp, duration = self.drying_data.get(key, (default_temp, default_time)) + plan[gate] = { + 'material': material, + 'temp': float(min(max_temp, temp)), + 'timer': int(duration), + } + return plan + diff --git a/extras/mmu/mmu_extruder_monitor.py b/extras/mmu/mmu_extruder_monitor.py new file mode 100644 index 000000000000..930bf36a4305 --- /dev/null +++ b/extras/mmu/mmu_extruder_monitor.py @@ -0,0 +1,153 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Helper class for callback based extruder monitoring. +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# + +from .mmu_shared import MmuError + +class ExtruderMonitor: + """ + Periodically samples the extruder position and notifies registered callbacks + when their per-callback movement thresholds are crossed (in either direction). + + - Each callback tracks its own signed movement accumulator since it was registered + or last triggered. The callback is invoked with the *signed* distance moved. + - Global enable/disable is controlled by (re)scheduling the reactor timer. + """ + + CHECK_INTERVAL = 0.5 # How often to check extruder movement (seconds) + + def __init__(self, mmu): + self.mmu = mmu + + self.enabled = True + self._last_pos = None # Last absolute extruder position + + # Per-callback state: + # { callback: {"threshold": float, "accum": float} } + self._callbacks = {} + + self._timer = self.mmu.reactor.register_timer(self._check_extruder_movement) + self.enable() # Start now + + + def enable(self): + """ + Globally enable monitoring and start the watchdog immediately. + """ + self.mmu.reactor.update_timer(self._timer, self.mmu.reactor.NOW) + self.enabled = True + + + def disable(self): + """ + Globally disable monitoring and stop the watchdog. + """ + self.mmu.reactor.update_timer(self._timer, self.mmu.reactor.NEVER) + self.enabled = False + + + def register_callback(self, cb, movement_threshold): + """ + Register a callback to be notified when accumulated movement crosses + the threshold (in either direction). + Args: + cb: Callable taking one positional arg: signed_distance_mm (float). + movement_threshold: Positive float, in mm. Must be > 0. + Behavior: + - Resets this callback's accumulator to 0 on registration. + - If already registered, updates the threshold and resets accumulator. + """ + if not callable(cb): + raise TypeError("cb must be callable") + if movement_threshold is None or movement_threshold <= 0: + raise ValueError("movement_threshold must be a positive float") + + self._callbacks[cb] = {"threshold": float(movement_threshold), "accum": 0.0} + + # Ensure the timer is running if globally enabled + if self.enabled: + self.mmu.reactor.update_timer(self._timer, self.mmu.reactor.NOW) + + + def remove_callback(self, cb): + """ + Unregister a previously registered callback. Silently ignores unknown cbs. + """ + self._callbacks.pop(cb, None) + + + def get_and_reset_accumulated(self, cb): + """ + Get the currently accumulated signed distance for a given callback + and reset its accumulator to zero. + Args: + cb: The same callback object that was passed to register_callback(). + Returns: + float: The signed accumulated distance in mm since registration or + the last reset/trigger. + Raises: + KeyError: If the callback is not currently registered. + """ + state = self._callbacks.get(cb) + if state is None: + raise KeyError("Callback is not registered with ExtruderMonitor") + + distance = state["accum"] + state["accum"] = 0.0 + return distance + + + # ---- Internal Implementation ---- + + def _check_extruder_movement(self, eventtime): + """ + Reactor timer entrypoint. Returns the next wakeup time. + """ + if not self.enabled or not self._callbacks: + return eventtime + self.CHECK_INTERVAL + + mcu = self.mmu.printer.lookup_object('mcu') + est_print_time = mcu.estimated_print_time(eventtime) + pos = self.mmu.toolhead.get_extruder().find_past_position(est_print_time) + + # Initialize last position on first successful read + if self._last_pos is None: + self._last_pos = pos + return eventtime + self.CHECK_INTERVAL + + # Compute signed delta since last sample + delta = pos - self._last_pos + self._last_pos = pos + + if delta != 0.0: + # Accumulate per-subscriber and trigger as needed + to_trigger = [] + for cb, state in self._callbacks.items(): + state["accum"] += delta # Signed accumulation + threshold = state["threshold"] + + if abs(state["accum"]) >= threshold: + # Capture the actual signed distance since last trigger and reset + signed_distance = state["accum"] + state["accum"] = 0.0 + to_trigger.append((cb, signed_distance)) + + # Second loop to avoid reentrancy issues if callbacks mutate subscriptions + for cb, signed_distance in to_trigger: + try: + cb(eventtime, signed_distance) + except MmuError as ee: + self.mmu.log_error("Error calling callback: %s" % str(ee)) + + # Reschedule + return eventtime + self.CHECK_INTERVAL diff --git a/extras/mmu/mmu_led_manager.py b/extras/mmu/mmu_led_manager.py new file mode 100644 index 000000000000..26449c744f56 --- /dev/null +++ b/extras/mmu/mmu_led_manager.py @@ -0,0 +1,747 @@ +# -*- coding: utf-8 -*- +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager class to centralize mmu_led operations accross all mmu_units +# +# Implements commands: +# MMU_SET_LED +# MMU_LED +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging + +# Happy Hare imports +from ..mmu_leds import MmuLeds + +# MMU subcomponent clases +from .mmu_shared import * + +class MmuLedManager: + def __init__(self, mmu): + self.mmu = mmu + self.mmu_machine = mmu.mmu_machine + self.inside_timer = False + self.pending_update = [False] * self.mmu_machine.num_units + self.effect_state = {} # Current state used to minimise updates {unit: {segment: effect}} + + # Event handlers + self.mmu.printer.register_event_handler("klippy:ready", self.handle_ready) + + # Register commands + self.mmu.gcode.register_command('MMU_SET_LED', self.cmd_MMU_SET_LED, desc = self.cmd_MMU_SET_LED_help) + self.mmu.gcode.register_command('MMU_LED', self.cmd_MMU_LED, desc = self.cmd_MMU_LED_help) + + def handle_ready(self): + self.setup_led_timer() + + def setup_led_timer(self): + self.led_timer = self.mmu.reactor.register_timer(self.led_timer_handler, self.mmu.reactor.NEVER) + + def led_timer_handler(self, eventtime): + self.inside_timer = True + try: + for unit in range(self.mmu_machine.num_units): + self.pending_update[unit] = False + self._set_led(unit, None, exit_effect='default', entry_effect='default', status_effect='default', logo_effect='default') + finally: + self.inside_timer = False + return self.mmu.reactor.NEVER + + def schedule_led_command(self, duration, unit): + if not self.inside_timer: + self.pending_update[unit] = True + self.mmu.reactor.update_timer(self.led_timer, self.mmu.reactor.monotonic() + duration) + + cmd_MMU_SET_LED_help = "Directly control MMU leds" + cmd_MMU_SET_LED_param_help = ( + "MMU_SET_LED: %s\n" % cmd_MMU_SET_LED_help + + "UNIT = #(int)\n" + + "GATE = #(int)\n" + + "EXIT_EFFECT = [off|gate_status|filament_color|slicer_color|r,g,b|_effect_]\n" + + "ENTRY_EFFECT = [off|gate_status|filament_color|slicer_color|r,g,b|_effect_]\n" + + "STATUS_EFFECT = [off|on|filament_color|slicer_color|r,g,b|_effect_]\n" + + "LOGO_EFFECT = [off|r,g,b|_effect_]\n" + + "DURATION = #.#(float) seconds\n" + + "FADETIME = #.#(float) seconds" + ) + def cmd_MMU_SET_LED(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + + help = bool(gcmd.get_int('HELP', 0, minval=0, maxval=1)) + unit = gcmd.get_int('UNIT', 0, minval=0, maxval=self.mmu_machine.num_units - 1) + gate = gcmd.get_int('GATE', None, minval=0, maxval=self.mmu_machine.num_gates - 1) + exit_effect = gcmd.get('EXIT_EFFECT', None) + entry_effect = gcmd.get('ENTRY_EFFECT', None) + status_effect = gcmd.get('STATUS_EFFECT', None) + logo_effect = gcmd.get('LOGO_EFFECT', None) + duration = gcmd.get_float('DURATION', None, minval=0) + fadetime = gcmd.get_float('FADETIME', 1, minval=0) + + if self.mmu_machine.get_mmu_unit_by_index(unit).leds is None: + self.mmu.log_error("No LEDs configured on MMU unit %d" % unit) + return + + if help: + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_SET_LED_param_help), color=True) + return + + self._set_led( + unit, gate, + entry_effect=entry_effect, + exit_effect=exit_effect, + status_effect=status_effect, + logo_effect=logo_effect, + fadetime=fadetime, + duration=duration + ) + + cmd_MMU_LED_help = "Manage mode of operation of optional MMU LED's" + cmd_MMU_LED_param_help = ( + "MMU_LED: %s\n" % cmd_MMU_LED_help + + "UNIT = #(int) default all units\n" + + "ENABLE = [0|1]\n" + + "ANIMATION = [0|1]\n" + + "EXIT_EFFECT = [off|gate_status|filament_color|slicer_color|r,g,b|_effect_]\n" + + "ENTRY_EFFECT = [off|gate_status|filament_color|slicer_color|r,g,b|_effect_]\n" + + "STATUS_EFFECT = [off|on|filament_color|slicer_color|r,g,b|_effect_]\n" + + "LOGO_EFFECT = [off|r,g,b|_effect_]\n" + + "REFRESH = [0|1]\n" + + "QUIET = [0|1]" + ) + def cmd_MMU_LED(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + help = bool(gcmd.get_int('HELP', 0, minval=0, maxval=1)) + refresh = bool(gcmd.get_int('REFRESH', 0, minval=0, maxval=1)) + unit = gcmd.get_int('UNIT', None, minval=0, maxval=self.mmu_machine.num_units - 1) + + mmu_units = [self.mmu_machine.get_mmu_unit_by_index(unit)] if unit is not None else self.mmu_machine.units + if all(mmu_unit.leds is None for mmu_unit in mmu_units): + self.mmu.log_error("No LEDs configured on MMU") + return + + if help: + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_LED_param_help), color=True) + return + + msg = "" + for mmu_unit in mmu_units: + leds = mmu_unit.leds + unit = mmu_unit.unit_index + + if leds: + exit_effect = gcmd.get('EXIT_EFFECT', leds.exit_effect) + entry_effect = gcmd.get('ENTRY_EFFECT', leds.entry_effect) + status_effect = gcmd.get('STATUS_EFFECT', leds.status_effect) + logo_effect = gcmd.get('LOGO_EFFECT', leds.logo_effect) + enabled = bool(gcmd.get_int('ENABLE', leds.enabled, minval=0, maxval=1)) + animation = bool(gcmd.get_int('ANIMATION', leds.animation, minval=0, maxval=1)) + + if leds.enabled and not enabled or refresh: + # Enabled to disabled or refresh + self._set_led( + unit, None, + exit_effect='off', + entry_effect='off', + status_effect='off', + logo_effect='off' + ) + else: + if leds.animation and not animation: + # Turning animation off so clear existing effects + self._set_led( + unit, None, + exit_effect='off', + entry_effect='off', + status_effect='off', + logo_effect='off', + fadetime=0 + ) + + if (leds.exit_effect != exit_effect or + leds.entry_effect != entry_effect or + leds.status_effect != status_effect or + leds.logo_effect != logo_effect or + leds.enabled != enabled or + leds.animation != animation or + refresh): + + leds.exit_effect = exit_effect + leds.entry_effect = entry_effect + leds.status_effect = status_effect + leds.logo_effect = logo_effect + leds.enabled = enabled + leds.animation = animation + + if enabled: + self._set_led( + unit, None, + exit_effect='default', + entry_effect='default', + status_effect='default', + logo_effect='default' + ) + + if not quiet: + available = lambda effect, enabled : ("'%s'" % str(effect)) if enabled else "unavailable" + msg += "\nUnit %s LEDs (%s)\n" % (unit, ("enabled" if enabled else "disabled")) + msg += " Animation: %s\n" % ("enabled" if animation else "disabled") + msg += " Default exit effect: %s\n" % available(exit_effect, leds.get_status()['exit']) + msg += " Default entry effect: %s\n" % available(entry_effect, leds.get_status()['entry']) + msg += " Default status effect: %s\n" % available(status_effect, leds.get_status()['status']) + msg += " Default logo effect: %s\n" % available(logo_effect, leds.get_status()['logo']) + else: + msg += "No LEDs configured on MMU unit %d" % unit + self.mmu.log_always(msg) + + # Called when an action has changed to update LEDs + # (this could be changed to klipper event) + def action_changed(self, action, old_action): + gate = self.mmu.gate_selected + + # Check for unit specific actions + if action in [self.mmu.ACTION_HOMING, self.mmu.ACTION_SELECTING]: + units_to_update = [self.mmu.unit_selected] + else: + units_to_update = range(self.mmu_machine.num_units) + + for unit in units_to_update: + + # Load sequence... + # idle -> loading -> load_ext -> [heat -> load_ext] -> loading* -> purging* -> loading* -> idle (*=excluded) + + if action == self.mmu.ACTION_LOADING: + if old_action not in [self.mmu.ACTION_LOADING_EXTRUDER, self.mmu.ACTION_PURGING]: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'loading'), + status_effect=self.effect_name(unit, 'loading'), + fadetime=0.5 + ) + + elif action == self.mmu.ACTION_LOADING_EXTRUDER: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'loading_extruder'), + status_effect=self.effect_name(unit, 'loading_extruder'), + fadetime=0.5 + ) + + elif action == self.mmu.ACTION_PURGING: + pass + + # Unload sequence... + # idle -> unloading -> form_tip/cut -> [heat -> form_tip/cut] -> unloading* -> unload_ext -> unloading + # [cutting* -> unloading*] -> idle (*=excluded) + elif action == self.mmu.ACTION_UNLOADING: + if old_action == self.mmu.ACTION_IDLE: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'unloading_extruder'), + status_effect=self.effect_name(unit, 'unloading_extruder'), + fadetime=0.5 + ) + + elif old_action not in [ + self.mmu.ACTION_FORMING_TIP, + self.mmu.ACTION_CUTTING_FILAMENT + ]: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'unloading'), + status_effect=self.effect_name(unit, 'unloading'), + fadetime=0.5 + ) + + elif action == self.mmu.ACTION_UNLOADING_EXTRUDER: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'unloading_extruder'), + status_effect=self.effect_name(unit, 'unloading_extruder'), + fadetime=0.5 + ) + + elif action in [self.mmu.ACTION_FORMING_TIP, self.mmu.ACTION_FORMING_TIP]: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'unloading_extruder'), + status_effect=self.effect_name(unit, 'unloading_extruder'), + fadetime=0.5 + ) + + elif action == self.mmu.ACTION_CUTTING_FILAMENT: + pass + + # Other actions... + + elif action == self.mmu.ACTION_HEATING: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'heating'), + status_effect=self.effect_name(unit, 'heating') + ) + + elif action == self.mmu.ACTION_IDLE: + self._set_led( + unit, None, + exit_effect='default', + status_effect='default' + ) + + # Type-A MMU actions involving selector (unit specific)... + + # idle -> home -> select -> home* -> idle (*=excluded) + elif action == self.mmu.ACTION_HOMING: + if old_action == self.mmu.ACTION_IDLE: + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'selecting'), + status_effect=self.effect_name(unit, 'selecting'), + fadetime=0 + ) + + # idle -> select -> idle + elif action == self.mmu.ACTION_SELECTING: + if old_action not in [self.mmu.ACTION_CHECKING]: + self._set_led( + unit, None, + exit_effect='default', + status_effect=self.effect_name(unit, 'selecting'), + fadetime=0 + ) + + # idle -> check -> select* -> check* -> select* -> check* -> idle + elif action == self.mmu.ACTION_CHECKING: + if old_action == self.mmu.ACTION_IDLE: + self._set_led( + unit, None, + exit_effect='default', + status_effect=self.effect_name(unit, 'checking') + ) + + # Called when print state changes to update LEDs + # (this could be changed to klipper event) + def print_state_changed(self, state, old_state): + gate = self.mmu.gate_selected + if state in ['initialized', 'printing', 'ready', 'cancelled', 'standby']: + units_to_update = range(self.mmu_machine.num_units) + else: + units_to_update = [self.mmu.unit_selected] + + for unit in units_to_update: + if state == "initialized": + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'initialized'), + entry_effect=self.effect_name(unit, 'initialized'), + status_effect=self.effect_name(unit, 'initialized'), + duration=8 + ) + + elif state == "printing": + self._set_led( + unit, None, + exit_effect='default', + entry_effect='default', + status_effect='default' + ) + + elif state == "pause_locked": + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'error'), + status_effect=self.effect_name(unit, 'error') + ) + + elif state == "paused": + self._set_led( + unit, gate, # Focus to specific gate + exit_effect=self.effect_name(unit, 'error'), + status_effect=self.effect_name(unit, 'error') + ) + + elif state == "ready": + self._set_led( + unit, None, + exit_effect='default', + entry_effect='default', + status_effect='default' + ) + + elif state == "complete": + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'complete'), + status_effect='default', + duration=10 + ) + + elif state == "error": + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'error'), + status_effect='default', + duration=10 + ) + + elif state == "cancelled": + self._set_led( + unit, None, + exit_effect='default', + entry_effect='default', + status_effect='default' + ) + + elif state == "standby": + self._set_led( + unit, None, + exit_effect='off', + entry_effect='off', + status_effect='off', + logo_effect='off' + ) + + # Called when gate map is updated to update LEDs + # (this could be changed to klipper event) + def gate_map_changed(self, gate): + if gate is not None and gate < 0: + gate = None + gate_effects = {'gate_status', 'filament_color', 'slicer_color'} + units = [self.mmu_machine.get_mmu_unit_by_gate(gate)] if gate is not None else self.mmu_machine.units + for mmu_unit in units: + leds = mmu_unit.leds + if not leds: + continue + + entry_effect = leds.entry_effect if leds.entry_effect in gate_effects else None + exit_effect = leds.exit_effect if leds.exit_effect in gate_effects else None + status_effect = leds.status_effect if leds.status_effect in gate_effects - {'gate_status'} else None + + if exit_effect or entry_effect or status_effect: + self._set_led( + mmu_unit.unit_index, + gate, + exit_effect=exit_effect, + entry_effect=entry_effect, + status_effect=status_effect + ) + + def effect_name(self, unit, operation): + leds = self.mmu_machine.get_mmu_unit_by_index(unit).leds + if leds: + return leds.get_effect(operation) + return '' + + # Make the necessary configuration changes to LED accross all mmu_units + # + # Effects for LED segments when not providing "action status feedback" can be: + # any effect name, "r,g,b" color, or built-in functional effects: + # "off" - LED's off + # "on" - LED's white + # "gate_status" - indicate gate availability + # "filament_color" - indicate filament color + # "slicer_color" - display slicer defined color for each gate + def _set_led(self, unit, gate, duration=None, fadetime=1, exit_effect=None, entry_effect=None, status_effect=None, logo_effect=None): + effects = { + 'entry': entry_effect, + 'exit': exit_effect, + 'status': status_effect, + 'logo': logo_effect, + } + + # Helper functions to make core logic simplier... + + # Iteration wrapper to easily detect the last loop + def with_last(iterable): + it = iter(iterable) + try: + prev = next(it) + except StopIteration: + return # Empty iterable + for item in it: + yield prev, False + prev = item + yield prev, True + + # List of led indexes (1-based on led_chain_spec) for iteration + def led_indexes(unit, segment, gate): + mmu_unit = self.mmu_machine.get_mmu_unit_by_index(unit) + num_leds = mmu_unit.leds.get_status()[segment] + if gate is None or gate < 0: + return list(range(1, num_leds + 1)) + leds_per_gate = num_leds // mmu_unit.num_gates + index0 = (gate - mmu_unit.first_gate) * leds_per_gate + 1 + return list(range(index0, index0 + leds_per_gate)) + + # Get raw "LEDS=" spec to stop an effect on virtual chain for given segment + def led_chain_spec(unit, segment): + return 'unit%d_mmu_%s_leds' % (unit, segment) + + # Get specific LEDS=" spec to stop an effect on whole segment or gate part of segment + def effect_leds_spec(unit, segment, gate): + if gate is not None and gate >= 0: + led_index_str = ','.join(map(str, led_indexes(unit, segment, gate))) + return "%s (%s)" % (led_chain_spec(unit, segment), led_index_str) # All leds for gate + return led_chain_spec(unit, segment) # All leds in segment + + # Get "EFFECT=" spec Used for applying effects + def effect_spec(unit, gate, effect): + if gate is not None and gate >= 0: + return "%s_%d" % (effect, gate) + return "unit%d_%s" % (unit, effect) + + # Translate desired effect into specific one based on context + def get_effective_effect(mmu_unit, segment, suggested): + if not mmu_unit.leds or not mmu_unit.leds.enabled or mmu_unit.leds.get_status()[segment] == 0: + return '' # Not available + elif suggested == 'default': + return mmu_unit.leds.get_status()['%s_effect' % segment] + return suggested + + # Stop the current effect on the gate led(s) + def stop_gate_effect(unit, segment, gate, fadetime=None): + if self.mmu_machine.get_mmu_unit_by_index(unit).leds.animation: + self.mmu.gcode.run_script_from_command( + "_MMU_STOP_LED_EFFECTS LEDS='%s' %s" % ( + effect_leds_spec(unit, segment, gate), + ('FADETIME=%d' % fadetime) if fadetime is not None else '' + ) + ) + + # Sets or replaces effect on the gate led(s) + def set_gate_effect(base_effect, unit, segment, gate, fadetime=None): + leds = self.mmu_machine.get_mmu_unit_by_index(unit).leds + if leds.animation: + self.mmu.gcode.run_script_from_command( + "_MMU_SET_LED_EFFECT EFFECT='%s' REPLACE=1 %s" % ( + effect_spec(unit, gate, "%s_%s" % (base_effect, segment)), + ('FADETIME=%d' % fadetime) if fadetime is not None else '' + ) + ) + else: + # Set all leds for effect to static rbg + rgb = leds.get_rgb_for_effect(base_effect) + set_gate_rgb(rgb, unit, segment, gate) + + # Sets rgb value of gate led(s) + def set_gate_rgb(rgb, unit, segment, gate, transmit=True): + # Normally there is only a single led per gate but some designs have many + for index, is_last in with_last(led_indexes(unit, segment, gate)): + self.mmu.gcode.run_script_from_command( + "SET_LED LED=%s INDEX=%d RED=%s GREEN=%s BLUE=%s TRANSMIT=%d" % ( + led_chain_spec(unit, segment), index, rgb[0], rgb[1], rgb[2], 1 if transmit and is_last else 0 + ) + ) + + # Stop any previous effect before setting rgb else it won't have an effect + def stop_effect_and_set_gate_rgb(rgb, unit, segment, gate, fadetime=None): + if fadetime: + set_gate_rgb(rgb, unit, segment, gate) + stop_gate_effect(unit, segment, gate, fadetime=fadetime) + else: + stop_gate_effect(unit, segment, gate) + set_gate_rgb(rgb, unit, segment, gate) + + + # + # Process LED update... + # + try: + mmu_unit = self.mmu_machine.get_mmu_unit_by_index(unit) + if ( + not mmu_unit.leds or + not mmu_unit.leds.enabled or + (gate is not None and not mmu_unit.manages_gate(gate)) + ): + # Ignore if unit doesn't have leds, is disabled for doesn't manage the specific gate + # (saves callers from checking) + return + + if gate is not None and gate < 0: + return + + # Don't allow changes to shortcut animations - important changes will be seen when update timer fires + if self.pending_update[unit]: + return + + # Schedule a return to defaults after duration + if duration is not None: + self.schedule_led_command(duration, unit) + + # + # Entry and Exit + # + for segment in ['exit', 'entry']: + effect = get_effective_effect(mmu_unit, segment, effects[segment]) + + # effect will be None if leds not configured for no led chain for that segment + #if not effect or self.effect_state.get(unit, {}).get(segment) == effect: + if not effect: + continue + + elif effect == "off": + + stop_effect_and_set_gate_rgb((0,0,0), unit, segment, gate, fadetime=fadetime) + + elif effect == "gate_status": # Filament availability (gate_map) + + def _effect_for_gate(g): + # Selected gate, with filament past extruder entry: force 'gate_selected' + if g == self.mmu.gate_selected and self.mmu.filament_pos > self.mmu.FILAMENT_POS_EXTRUDER_ENTRY: + return self.effect_name(unit, 'gate_selected') + + suffix = '_sel' if g == self.mmu.gate_selected else '' + status = self.mmu.gate_status[g] + + if status == self.mmu.GATE_UNKNOWN: + key = 'gate_unknown' + elif status > self.mmu.GATE_EMPTY: + key = 'gate_available' + else: + key = 'gate_empty' + + return self.effect_name(unit, '%s%s' % (key, suffix)) + + if gate is not None: + set_gate_effect(_effect_for_gate(gate), unit, segment, gate, fadetime=fadetime) + else: + for g in range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates): + set_gate_effect(_effect_for_gate(g), unit, segment, g, fadetime=fadetime) + + elif effect == "filament_color": + + def _resolve_filament_rgb(g): + rgb = self.mmu.gate_color_rgb[g] + if self.mmu.gate_status[g] == self.mmu.GATE_EMPTY: + return mmu_unit.leds.empty_light + if self.mmu.gate_color[g] == "": + return mmu_unit.leds.white_light + if rgb == (0, 0, 0): + return mmu_unit.leds.black_light + return rgb + + if gate is not None: + rgb = _resolve_filament_rgb(gate) + stop_effect_and_set_gate_rgb(rgb, unit, segment, gate) + else: + stop_gate_effect(unit, segment, None) + for g, is_last in with_last(range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates)): + rgb = _resolve_filament_rgb(g) + set_gate_rgb(rgb, unit, segment, g, transmit=is_last) + + elif effect == "slicer_color": + + def _resolve_slicer_rgb(g): + rgb = self.mmu.slicer_color_rgb[g] + if self.mmu.gate_status[g] == self.mmu.GATE_EMPTY: + return mmu_unit.leds.empty_light + if rgb == (0, 0, 0): + return mmu_unit.leds.black_light + return rgb + + if gate is not None: + rgb = _resolve_slicer_rgb(gate) + stop_effect_and_set_gate_rgb(rgb, unit, segment, gate) + else: + stop_gate_effect(unit, segment, None) # Stop all gates + for g, is_last in with_last(range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates)): + rgb = _resolve_slicer_rgb(g) + set_gate_rgb(rgb, unit, segment, g, transmit=is_last) + + elif isinstance(effect, tuple) or ',' in effect: # RGB color + rgb = MmuLeds.string_to_rgb(effect) + if gate is not None: + stop_effect_and_set_gate_rgb(rgb, unit, segment, gate) + else: + stop_gate_effect(unit, segment, None) # Stop all gates + for g, is_last in with_last(range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates)): + set_gate_rgb(rgb, unit, segment, g, transmit=is_last) + + elif effect != "": # Named effect + set_gate_effect(effect, unit, segment, gate, fadetime=fadetime) + + self.effect_state.setdefault(unit, {})[segment] = effect + + + # + # Status + # + segment = "status" + effect = get_effective_effect(mmu_unit, segment, effects[segment]) + + #if not effect or self.effect_state.get(unit, {}).get(segment) == effect: + if not effect: + pass + + elif effect == "off": + + stop_effect_and_set_gate_rgb((0,0,0), unit, segment, gate, fadetime=fadetime) + + elif effect in ["filament_color", "on"]: + + stop_gate_effect(unit, segment, None) + rgb = mmu_unit.leds.white_light + if self.mmu.gate_selected >= 0 and self.mmu.filament_pos > self.mmu.FILAMENT_POS_UNLOADED: + if effects[segment] != "on" and self.mmu.gate_color[self.mmu.gate_selected] != "": + rgb = self.mmu.gate_color_rgb[self.mmu.gate_selected] + if rgb == (0,0,0): + rgb = mmu_unit.leds.black_light + else: + rgb = mmu_unit.leds.black_light + set_gate_rgb(rgb, unit, segment, None) + + elif effect == "slicer_color": + + stop_gate_effect(unit, segment, None) + rgb = (0,0,0) + if self.mmu.gate_selected >= 0 and self.mmu.filament_pos > self.mmu.FILAMENT_POS_UNLOADED: + rgb = self.mmu.slicer_color_rgb[self.mmu.gate_selected] + set_gate_rgb(rgb, unit, segment, None) + + elif isinstance(effect, tuple) or ',' in effect: # RGB color + rgb = MmuLeds.string_to_rgb(effect) + stop_effect_and_set_gate_rgb(rgb, unit, segment, None) + + elif effect != "": # Named effect + set_gate_effect(effect, unit, segment, None, fadetime=fadetime) + + self.effect_state.setdefault(unit, {})[segment] = effect + + # + # Logo + # + segment = "logo" + effect = get_effective_effect(mmu_unit, segment, effects[segment]) + + #if not effect or self.effect_state.get(unit, {}).get(segment) == effect: + if not effect: + pass + + elif effect == "off": + + stop_effect_and_set_gate_rgb((0,0,0), unit, segment, None, fadetime=fadetime) + + elif isinstance(effect, tuple) or ',' in effect: # RGB color + rgb = MmuLeds.string_to_rgb(effect) + stop_effect_and_set_gate_rgb(rgb, unit, segment, None) + + elif effect != "": # Named effect + + set_gate_effect(effect, unit, segment, None, fadetime=fadetime) + + self.effect_state.setdefault(unit, {})[segment] = effect + + except Exception as e: + # Don't let a misconfiguration ruin a print! + self.mmu.log_error("Error updating leds: %s" % str(e)) diff --git a/extras/mmu/mmu_logger.py b/extras/mmu/mmu_logger.py new file mode 100644 index 000000000000..78834cb43f3d --- /dev/null +++ b/extras/mmu/mmu_logger.py @@ -0,0 +1,80 @@ +# Happy Hare MMU Software +# Logging helpers +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, logging.handlers, threading, os, queue, atexit + +# MMU subcomponent clases +from .mmu_shared import * + +class MmuLogger: + def __init__(self, logfile_path): + name = os.path.splitext(os.path.basename(logfile_path))[0] + self.logger = logging.getLogger(name) + + self.queue_listener = None + if not any(isinstance(h, QueueHandler) for h in self.logger.handlers): + handler = logging.handlers.TimedRotatingFileHandler(logfile_path, when='midnight', backupCount=3) + handler.setFormatter(MultiLineFormatter('%(asctime)s %(message)s', datefmt='%H:%M:%S')) + self.queue_listener = QueueListener(handler) + self.logger.addHandler(QueueHandler(self.queue_listener.bg_queue)) + + self.logger.setLevel(logging.INFO) + self.logger.propagate = False + atexit.register(self.shutdown) + + def log(self, message): + self.logger.info(message.replace(UI_SPACE, ' ').replace(UI_SEPARATOR, ' ')) + + def shutdown(self): + if self.queue_listener is not None: + self.queue_listener.stop() + +# Poll log queue on background thread and log each message to logfile +class QueueHandler(logging.Handler): + def __init__(self, log_queue): + super(QueueHandler, self).__init__() + self.queue = log_queue + + def emit(self, record): + try: + self.queue.put_nowait(record) + except Exception: + self.handleError(record) + +class QueueListener: + def __init__(self, handler): + self.bg_queue = queue.Queue() + self.handler = handler + self.bg_thread = threading.Thread(target=self._bg_thread) + self.bg_thread.daemon = True + self.bg_thread.start() + + def _bg_thread(self): + while True: + record = self.bg_queue.get(True) + if record is None: + break + self.handler.handle(record) + + def stop(self): + self.bg_queue.put_nowait(None) + self.bg_thread.join() + +# Class to improve formatting of multi-line messages +class MultiLineFormatter(logging.Formatter): + def format(self, record): + indent = ' ' * 9 + formatted_message = super(MultiLineFormatter, self).format(record) + if record.exc_text: + # Don't modify exception stack traces + return formatted_message + return formatted_message.replace('\n', '\n' + indent) diff --git a/extras/mmu/mmu_selector.py b/extras/mmu/mmu_selector.py new file mode 100644 index 000000000000..da53f4f0b264 --- /dev/null +++ b/extras/mmu/mmu_selector.py @@ -0,0 +1,2204 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Implementation of various selector variations: +# +# VirtualSelector: +# Implements selector for type-B MMU's with gear driver per gate +# - Uses gear driver stepper per-gate +# - For type-B designs like BoxTurtle, KMS, QuattroBox +# +# LinearSelector: +# Implements Linear Selector for type-A MMU's without servo +# - Stepper controlled linear movement with endstop +# - Supports type-A with combined selection and filament gripping line ERCFv3 +# +# LinearServoSelector: +# Implements Linear Selector for type-A MMU's with servo +# - Stepper controlled linear movement with endstop +# - Servo controlled filament gripping +# - Supports type-A classic MMU's like ERCFv1.1, ERCFv2.0 and Tradrack +# +# LinearMultiGearSelector: +# Implements Linear Selector for type-C MMU's with multiple gear steppers: +# - Uses gear driver stepper per-gate +# - Uses selector stepper for gate selection with endstop +# - Supports type-A classic MMU's like ERCF and Tradrack +# +# Rotary Selector +# - Rotary Selector for 3D Chamelon using stepper selection +# without servo +# +# Macro Selector +# - Universal selector control via macros +# - Great for experimention +# +# Servo Selector +# - Servo based Selector for PicoMMU and clones +# +# Indexed Selector +# - Stepper based Selector for ViViD with per-gate index sensors +# +# +# Implements commands (selector dependent): +# MMU_CALIBRATE_SELECTOR +# MMU_SOAKTEST_SELECTOR +# MMU_SERVO +# MMU_GRIP +# MMU_RELEASE +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import random, logging, math, re + +# Klipper imports +from ..homing import Homing, HomingMove + +# Happy Hare imports +from .. import mmu_machine +from ..mmu_machine import MmuToolHead + +# MMU subcomponent clases +from .mmu_shared import MmuError + + +################################################################################ +# Base Selector Class +################################################################################ + +class BaseSelector: + + def __init__(self, mmu): + self.mmu = mmu + self.mmu.managers.append(self) + self.is_homed = False + self.mmu_unit = 0 + + def reinit(self): + pass + + def handle_connect(self): + pass + + def handle_ready(self): + pass + + def handle_disconnect(self): + pass + + def bootup(self): + pass + + def home(self, force_unload = None): + pass + + def select_gate(self, gate): + pass + + def restore_gate(self, gate): + pass + + def filament_drive(self): + pass + + def filament_release(self, measure=False): + return 0. # Fake encoder movement + + def filament_hold_move(self): + pass + + def get_filament_grip_state(self): + return self.mmu.FILAMENT_DRIVE_STATE + + def disable_motors(self): + pass + + def enable_motors(self): + pass + + def buzz_motor(self, motor): + return False + + def has_bypass(self): + return self.mmu.mmu_machine.has_bypass + + def get_status(self, eventtime): + return { + 'has_bypass': self.has_bypass() + } + + def get_mmu_status_config(self): + return "\nSelector Type: %s" % self.__class__.__name__ + + def set_test_config(self, gcmd): + pass + + def get_test_config(self): + return "" + + def check_test_config(self, param): + return True + + def get_uncalibrated_gates(self, check_gates): + return [] + + + +################################################################################ +# VirtualSelector: +# Implements selector for type-B MMU's with gear driver per gate +# - Uses gear driver stepper per-gate +# - For type-B designs like BoxTurtle, KMS, QuattroBox +################################################################################ + +class VirtualSelector(BaseSelector, object): + + def __init__(self, mmu): + super(VirtualSelector, self).__init__(mmu) + self.is_homed = True + + # Read all controller parameters related to selector or servo to stop klipper complaining. This + # is done to allow for uniform and shared mmu_parameters.cfg file regardless of configuration. + for option in ['selector_', 'servo_', 'cad_']: + for key in mmu.config.get_prefix_options(option): + _ = mmu.config.get(key) + + # Selector "Interface" methods --------------------------------------------- + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR # No calibration necessary + + def select_gate(self, gate): + if gate == self.mmu.gate_selected: return + self.mmu_toolhead.select_gear_stepper(gate) # Select correct drive stepper or none if bypass + + def restore_gate(self, gate): + self.mmu.mmu_toolhead.select_gear_stepper(gate) # Select correct drive stepper or none if bypass + + def get_mmu_status_config(self): + msg = "\nVirtual selector" + return msg + + + +################################################################################ +# LinearSelector: +# Implements Linear Selector for type-A MMU's with servo +# - Stepper controlled linear movement with endstop +# - Optional servo controlled filament gripping +# - Supports type-A classic MMU's like ERCF and Tradrack +################################################################################ + +class LinearSelector(BaseSelector, object): + + # mmu_vars.cfg variables + VARS_MMU_SELECTOR_OFFSETS = "mmu_selector_offsets" + VARS_MMU_SELECTOR_BYPASS = "mmu_selector_bypass" + + def __init__(self, mmu): + super(LinearSelector, self).__init__(mmu) + self.bypass_offset = -1 + + # Process config + self.selector_move_speed = mmu.config.getfloat('selector_move_speed', 200, minval=1.) + self.selector_homing_speed = mmu.config.getfloat('selector_homing_speed', 100, minval=1.) + self.selector_touch_speed = mmu.config.getfloat('selector_touch_speed', 60, minval=1.) + self.selector_touch_enabled = mmu.config.getint('selector_touch_enabled', 1, minval=0, maxval=1) + + # To simplfy config CAD related parameters are set based on vendor and version setting + # + # These are default for ERCFv1.1 - the first MMU supported by Happy Hare + # cad_gate0_pos - approximate distance from endstop to first gate + # cad_gate_width - width of each gate + # cad_bypass_offset - distance from end of travel to the bypass + # cad_last_gate_offset - distance from end of travel to last gate + # cad_block_width - width of bearing block (ERCF v1.1) + # cad_bypass_block_width - width of bypass block (ERCF v1.1) + # cad_bypass_block_delta - distance from previous gate to bypass (ERCF v1.1) + # + self.cad_gate0_pos = 4.2 + self.cad_gate_width = 21. + self.cad_bypass_offset = 0 + self.cad_last_gate_offset = 2. + self.cad_block_width = 5. + self.cad_bypass_block_width = 6. + self.cad_bypass_block_delta = 9. + self.cad_selector_tolerance = 15. + + # Specific vendor build parameters / tuning. + if self.mmu.mmu_machine.mmu_vendor.lower() == mmu_machine.VENDOR_ERCF.lower(): + if self.mmu.mmu_machine.mmu_version >= 2.0: # V2 community edition + self.cad_gate0_pos = 4.0 + self.cad_gate_width = 23. + self.cad_bypass_offset = 0.72 + self.cad_last_gate_offset = 14.4 + + else: # V1.1 original + # Modifications: + # t = TripleDecky filament blocks + # s = Springy sprung servo selector + # b = Binky encoder upgrade + if "t" in self.mmu.mmu_machine.mmu_version_string: + self.cad_gate_width = 23. # Triple Decky is wider filament block + self.cad_block_width = 0. # Bearing blocks are not used + + if "s" in self.mmu.mmu_machine.mmu_version_string: + self.cad_last_gate_offset = 1.2 # Springy has additional bump stops + + elif self.mmu.mmu_machine.mmu_vendor.lower() == mmu_machine.VENDOR_TRADRACK.lower(): + self.cad_gate0_pos = 2.5 + self.cad_gate_width = 17. + self.cad_bypass_offset = 0 # Doesn't have bypass + self.cad_last_gate_offset = 0. # Doesn't have reliable hard stop at limit of travel + + # But still allow all CAD parameters to be customized + self.cad_gate0_pos = mmu.config.getfloat('cad_gate0_pos', self.cad_gate0_pos, minval=0.) + self.cad_gate_width = mmu.config.getfloat('cad_gate_width', self.cad_gate_width, above=0.) + self.cad_bypass_offset = mmu.config.getfloat('cad_bypass_offset', self.cad_bypass_offset, minval=0.) + self.cad_last_gate_offset = mmu.config.getfloat('cad_last_gate_offset', self.cad_last_gate_offset, above=0.) + self.cad_block_width = mmu.config.getfloat('cad_block_width', self.cad_block_width, above=0.) # ERCF v1.1 only + self.cad_bypass_block_width = mmu.config.getfloat('cad_bypass_block_width', self.cad_bypass_block_width, above=0.) # ERCF v1.1 only + self.cad_bypass_block_delta = mmu.config.getfloat('cad_bypass_block_delta', self.cad_bypass_block_delta, above=0.) # ERCF v1.1 only + self.cad_selector_tolerance = mmu.config.getfloat('cad_selector_tolerance', self.cad_selector_tolerance, above=0.) # Extra movement allowed by selector + + # Sub components (optional servo) + if isinstance(self, LinearServoSelector): + self.servo = LinearSelectorServo(mmu) if isinstance(self, LinearServoSelector) else None + else: + self.servo = None + # Read all controller parameters related to to stop klipper complaining. This is done to allow + # for uniform and shared mmu_parameters.cfg file regardless of configuration. + for option in ['servo_']: + for key in mmu.config.get_prefix_options(option): + _ = mmu.config.get(key) + + # Register GCODE commands specific to this module + gcode = mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_CALIBRATE_SELECTOR', self.cmd_MMU_CALIBRATE_SELECTOR, desc = self.cmd_MMU_CALIBRATE_SELECTOR_help) + gcode.register_command('MMU_SOAKTEST_SELECTOR', self.cmd_MMU_SOAKTEST_SELECTOR, desc = self.cmd_MMU_SOAKTEST_SELECTOR_help) + + # Selector stepper setup before MMU toolhead is instantiated + section = mmu_machine.SELECTOR_STEPPER_CONFIG + if mmu.config.has_section(section): + # Inject options into selector stepper config regardless or what user sets + mmu.config.fileconfig.set(section, 'position_min', -1.) + mmu.config.fileconfig.set(section, 'position_max', self._get_max_selector_movement()) + mmu.config.fileconfig.set(section, 'homing_speed', self.selector_homing_speed) + + # Selector "Interface" methods --------------------------------------------- + + def reinit(self): + # Sub components + if self.servo: + self.servo.reinit() + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.selector_rail = self.mmu_toolhead.get_kinematics().rails[0] + self.selector_stepper = self.selector_rail.steppers[0] + + # Load selector offsets (calibration set with MMU_CALIBRATE_SELECTOR) ------------------------------- + self.selector_offsets = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_OFFSETS, None) + if self.selector_offsets: + # Ensure list size + if len(self.selector_offsets) == self.mmu.num_gates: + self.mmu.log_debug("Loaded saved selector offsets: %s" % self.selector_offsets) + else: + self.mmu.log_error("Incorrect number of gates specified in %s. Adjusted length" % self.VARS_MMU_SELECTOR_OFFSETS) + self.selector_offsets = self._ensure_list_size(self.selector_offsets, self.mmu.num_gates) + + if not any(x == -1 for x in self.selector_offsets): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + else: + self.mmu.log_always("Warning: Selector offsets not found in mmu_vars.cfg. Probably not calibrated") + self.selector_offsets = [-1] * self.mmu.num_gates + self.mmu.save_variables.allVariables[self.VARS_MMU_SELECTOR_OFFSETS] = self.selector_offsets + + self.bypass_offset = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_BYPASS, -1) + if self.bypass_offset > 0: + self.mmu.log_debug("Loaded saved bypass offset: %s" % self.bypass_offset) + else: + self.bypass_offset = -1 # Ensure -1 value for uncalibrated / non-existent + self.mmu.save_variables.allVariables[self.VARS_MMU_SELECTOR_BYPASS] = self.bypass_offset + + # See if we have a TMC controller setup with stallguard + self.selector_tmc = None + for chip in mmu_machine.TMC_CHIPS: + if self.selector_tmc is None: + self.selector_tmc = self.mmu.printer.lookup_object('%s %s' % (chip, mmu_machine.SELECTOR_STEPPER_CONFIG), None) + if self.selector_tmc is not None: + self.mmu.log_debug("Found %s on selector_stepper. Stallguard 'touch' movement and recovery possible." % chip) + if self.selector_tmc is None: + self.mmu.log_debug("TMC driver not found for selector_stepper, cannot use 'touch' movement and recovery") + + # Sub components + if self.servo: + self.servo.handle_connect() + + def _ensure_list_size(self, lst, size, default_value=-1): + lst = lst[:size] + lst.extend([default_value] * (size - len(lst))) + return lst + + def handle_disconnect(self): + # Sub components + if self.servo: + self.servo.handle_disconnect() + + def handle_ready(self): + # Sub components + if self.servo: + self.servo.handle_ready() + + def home(self, force_unload = None): + if self.mmu.check_if_bypass(): return + with self.mmu.wrap_action(self.mmu.ACTION_HOMING): + self.mmu.log_info("Homing MMU...") + if force_unload is not None: + self.mmu.log_debug("(asked to %s)" % ("force unload" if force_unload else "not unload")) + if force_unload is True: + # Forced unload case for recovery + self.mmu.unload_sequence(check_state=True) + elif force_unload is None and self.mmu.filament_pos != self.mmu.FILAMENT_POS_UNLOADED: + # Automatic unload case + self.mmu.unload_sequence() + self._home_selector() + + # Physically move selector to correct gate position + def select_gate(self, gate): + if gate == self.mmu.gate_selected: return + with self.mmu.wrap_action(self.mmu.ACTION_SELECTING): + self.filament_hold_move() + if gate == self.mmu.TOOL_GATE_BYPASS: + self._position(self.bypass_offset) + elif gate >= 0: + self._position(self.selector_offsets[gate]) + + # Correct rail position for selector + def restore_gate(self, gate): + if gate == self.mmu.TOOL_GATE_BYPASS: + self.set_position(self.bypass_offset) + elif gate >= 0: + self.set_position(self.selector_offsets[gate]) + + def filament_drive(self, buzz_gear=True): + if self.servo: + self.servo.servo_down(buzz_gear=buzz_gear) + + def filament_release(self, measure=False): + if self.servo: + return self.servo.servo_up(measure=measure) + + def filament_hold_move(self): # AKA position for holding filament and moving selector + if self.servo: + self.servo.servo_move() + + def get_filament_grip_state(self): + if self.servo: + return self.servo.get_filament_grip_state() + + def disable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_disable(self.mmu_toolhead.get_last_move_time()) + self.is_homed = False + if self.servo: + self.servo.disable_motors() + + def enable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_enable(self.mmu_toolhead.get_last_move_time()) + + def buzz_motor(self, motor): + if motor == "selector": + pos = self.mmu_toolhead.get_position()[0] + self.move(None, pos + 5, wait=False) + self.move(None, pos - 5, wait=False) + self.move(None, pos, wait=False) + elif motor == "servo" and self.servo: + self.servo.buzz_motor() + else: + return False + return True + + def has_bypass(self): + return self.mmu.mmu_machine.has_bypass and self.bypass_offset >= 0 + + def get_status(self, eventtime): + status = super(LinearSelector, self).get_status(eventtime) + if self.servo: + status.update(self.servo.get_status(eventtime)) + return status + + def get_mmu_status_config(self): + msg = "\nSelector is NOT HOMED" if not self.is_homed else "" + if self.servo: + msg += self.servo.get_mmu_status_config() + return msg + + def set_test_config(self, gcmd): + self.selector_move_speed = gcmd.get_float('SELECTOR_MOVE_SPEED', self.selector_move_speed, minval=1.) + self.selector_homing_speed = gcmd.get_float('SELECTOR_HOMING_SPEED', self.selector_homing_speed, minval=1.) + self.selector_touch_speed = gcmd.get_float('SELECTOR_TOUCH_SPEED', self.selector_touch_speed, minval=1.) + self.selector_touch_enabled = gcmd.get_int('SELECTOR_TOUCH_ENABLED', self.selector_touch_enabled, minval=0, maxval=1) + + # Sub components + if self.servo: + self.servo.set_test_config(gcmd) + + def get_test_config(self): + msg = "\n\nSELECTOR:" + msg += "\nselector_move_speed = %.1f" % self.selector_move_speed + msg += "\nselector_homing_speed = %.1f" % self.selector_homing_speed + msg += "\nselector_touch_speed = %.1f" % self.selector_touch_speed + msg += "\nselector_touch_enabled = %d" % self.selector_touch_enabled + + # Sub components + if self.servo: + msg += self.servo.get_test_config() + + return msg + + def check_test_config(self, param): + return (vars(self).get(param) is None) and (self.servo is None or self.servo.check_test_config(param)) + + def get_uncalibrated_gates(self, check_gates): + return [gate for gate, value in enumerate(self.selector_offsets) if value == -1 and gate in check_gates] + + # Internal Implementation -------------------------------------------------- + + cmd_MMU_CALIBRATE_SELECTOR_help = "Calibration of the selector positions or postion of specified gate" + def cmd_MMU_CALIBRATE_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + single = gcmd.get_int('SINGLE', 0, minval=0, maxval=1) + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.mmu.mmu_machine.num_gates - 1) + if gate == -1 and gcmd.get_int('BYPASS', -1, minval=0, maxval=1) == 1: + gate = self.mmu.TOOL_GATE_BYPASS + + try: + with self.mmu.wrap_sync_gear_to_extruder(): + self.mmu.calibrating = True + self.mmu.reinit() + self.filament_hold_move() + successful = False + if gate != -1: + successful = self._calibrate_selector(gate, extrapolate=not single, save=save) + else: + successful = self._calibrate_selector_auto(save=save, v1_bypass_block=gcmd.get_int('BYPASS_BLOCK', -1, minval=1, maxval=3)) + + if not any(x == -1 for x in self.selector_offsets): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + + # If not fully calibrated turn off the selector stepper to ease next step, else activate by homing + if successful and self.mmu.calibration_status & self.mmu.CALIBRATED_SELECTOR: + self.mmu.log_always("Selector calibration complete") + self.mmu.select_tool(0) + else: + self.mmu.motors_onoff(on=False, motor="selector") + + except MmuError as ee: + self.mmu.handle_mmu_error(str(ee)) + finally: + self.mmu.calibrating = False + + cmd_MMU_SOAKTEST_SELECTOR_help = "Soak test of selector movement" + def cmd_MMU_SOAKTEST_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_loaded(): return + if self.mmu.check_if_not_calibrated(self.mmu.CALIBRATED_SELECTOR): return + loops = gcmd.get_int('LOOP', 100) + servo = bool(gcmd.get_int('SERVO', 0)) + home = bool(gcmd.get_int('HOME', 0)) + try: + with self.mmu.wrap_sync_gear_to_extruder(): + if home: + self.home() + for l in range(loops): + self.mmu.log_always("Testing loop %d / %d" % (l + 1, loops)) + tool = random.randint(0, self.mmu.num_gates) + if tool == self.mmu.num_gates: + self.mmu.select_bypass() + else: + if random.randint(0, 10) == 0 and home: + self.mmu.home(tool=tool) + else: + self.mmu.select_tool(tool) + if servo: + self.filament_drive() + except MmuError as ee: + self.mmu.handle_mmu_error("Soaktest abandoned because of error: %s" % str(ee)) + + def _get_max_selector_movement(self, gate=-1): + n = gate if gate >= 0 else self.mmu.num_gates - 1 + + if self.mmu.mmu_machine.mmu_vendor == mmu_machine.VENDOR_ERCF: + # ERCF Designs + if self.mmu.mmu_machine.mmu_version >= 2.0 or "t" in self.mmu.mmu_machine.mmu_version_string: + max_movement = self.cad_gate0_pos + (n * self.cad_gate_width) + else: + max_movement = self.cad_gate0_pos + (n * self.cad_gate_width) + (n//3) * self.cad_block_width + else: + # Everything else + max_movement = self.cad_gate0_pos + (n * self.cad_gate_width) + + max_movement += self.cad_last_gate_offset if gate in [self.mmu.TOOL_GATE_UNKNOWN] else 0. + max_movement += self.cad_selector_tolerance + return max_movement + + # Manual selector offset calibration + def _calibrate_selector(self, gate, extrapolate=True, save=True): + gate_str = lambda gate : ("gate %d" % gate) if gate >= 0 else "bypass" + + max_movement = self._get_max_selector_movement(gate) + self.mmu.log_always("Measuring the selector position for %s..." % gate_str(gate)) + traveled, found_home = self.measure_to_home() + + # Test we actually homed + if not found_home: + self.mmu.log_error("Selector didn't find home position") + return False + + # Warn and don't save if the measurement is unexpected + if traveled > max_movement: + self.mmu.log_always("Selector move measured %.1fmm. More than the anticipated maximum of %.1fmm. Save disabled\nIt is likely that your basic MMU dimensions are incorrect in mmu_parameters.cfg. Check vendor/version and optional 'cad_*' parameters" % (traveled, max_movement)) + save = 0 + else: + self.mmu.log_always("Selector move measured %.1fmm" % traveled) + + if save: + if gate >= 0: + self.selector_offsets[gate] = round(traveled, 1) + if ( + extrapolate and gate == self.mmu.num_gates - 1 and self.selector_offsets[0] > 0 or + extrapolate and gate == 0 and self.selector_offsets[-1] > 0 + ): + # Distribute selector spacing + spacing = (self.selector_offsets[-1] - self.selector_offsets[0]) / (self.mmu.num_gates - 1) + self.selector_offsets = [round(self.selector_offsets[0] + i * spacing, 1) for i in range(self.mmu.num_gates)] + else: + extrapolate = False + self.mmu.save_variable(self.VARS_MMU_SELECTOR_OFFSETS, self.selector_offsets, write=True) + else: + self.bypass_offset = round(traveled, 1) + extrapolate = False + self.mmu.save_variable(self.VARS_MMU_SELECTOR_BYPASS, self.bypass_offset, write=True) + + if extrapolate: + self.mmu.log_always("All selector offsets have been extrapolated and saved:\n%s" % self.selector_offsets) + else: + self.mmu.log_always("Selector offset (%.1fmm) for %s has been saved" % (traveled, gate_str(gate))) + if gate == 0: + self.mmu.log_always("Run MMU_CALIBRATE_SELECTOR again with GATE=%d to extrapolate all gate positions. Use SINGLE=1 to force calibration of only one gate" % (self.mmu.num_gates - 1)) + return True + + # Fully automated selector offset calibration + # Strategy is to find the two end gates, infer and set number of gates and distribute selector positions + # Assumption: the user has manually positioned the selector aligned with gate 0 before calling. Doesn't work + # with as well with open ended designs like Tradrack. Use "manual" calibration routine above for that + def _calibrate_selector_auto(self, save=True, v1_bypass_block=-1): + self.mmu.log_always("Auto calibrating the selector. Excuse the whizz, bang, buzz, clicks...") + + # Step 1 - position of gate 0 + self.mmu.log_always("Measuring the selector position for gate 0...") + traveled, found_home = self.measure_to_home() + if not found_home or traveled > self.cad_gate0_pos + self.cad_selector_tolerance: + self.mmu.log_error("Selector didn't find home position or distance moved (%.1fmm) was larger than expected.\nAre you sure you aligned selector with gate 0 and removed filament?" % traveled) + return False + gate0_pos = traveled + + # Step 2 - end of selector + max_movement = self._get_max_selector_movement() + self.mmu.log_always("Searching for end of selector... (up to %.1fmm)" % max_movement) + if self.use_touch_move(): + _,found_home = self.homing_move("Detecting end of selector movement", max_movement, homing_move=1, endstop_name=self.mmu.SENSOR_SELECTOR_TOUCH) + else: + # This might not sound good! + self.move("Ensure we are clear off the physical endstop", self.cad_gate0_pos) + self.move("Forceably detecting end of selector movement", max_movement, speed=self.selector_homing_speed) + found_home = True + if not found_home: + msg = "Didn't detect the end of the selector" + if self.cad_last_gate_offset > 0: + self.mmu.log_error(msg) + return False + else: + self.mmu.log_always(msg) + + # Step 3a - selector length + self.mmu.log_always("Measuring the full selector length...") + traveled, found_home = self.measure_to_home() + if not found_home: + self.mmu.log_error("Selector didn't find home position after full length move") + return False + self.mmu.log_always("Maximum selector movement is %.1fmm" % traveled) + + # Step 3b - bypass and last gate position (measured back from limit of travel) + if self.cad_bypass_offset > 0: + bypass_pos = traveled - self.cad_bypass_offset + else: + bypass_pos = -1 + if self.cad_last_gate_offset > 0: + # This allows the error to be averaged + last_gate_pos = traveled - self.cad_last_gate_offset + else: + # This simply assumes theoretical distance + last_gate_pos = gate0_pos + (self.mmu.num_gates - 1) * self.cad_gate_width + + # Step 4 - the calcs + length = last_gate_pos - gate0_pos + self.mmu.log_debug("Results: gate0_pos=%.1f, last_gate_pos=%.1f, length=%.1f" % (gate0_pos, last_gate_pos, length)) + selector_offsets = [] + + if self.mmu.mmu_machine.mmu_vendor.lower() == mmu_machine.VENDOR_ERCF.lower() and self.mmu.mmu_machine.mmu_version == 1.1: + # ERCF v1.1 special case + num_gates = adj_gate_width = int(round(length / (self.cad_gate_width + self.cad_block_width / 3))) + 1 + num_blocks = (num_gates - 1) // 3 + bypass_offset = -1 + if num_gates > 1: + if v1_bypass_block >= 0: + adj_gate_width = (length - (num_blocks - 1) * self.cad_block_width - self.cad_bypass_block_width) / (num_gates - 1) + else: + adj_gate_width = (length - num_blocks * self.cad_block_width) / (num_gates - 1) + self.mmu.log_debug("Adjusted gate width: %.1f" % adj_gate_width) + for i in range(num_gates): + bypass_adj = (self.cad_bypass_block_width - self.cad_block_width) if (i // 3) >= v1_bypass_block else 0. + selector_offsets.append(round(gate0_pos + (i * adj_gate_width) + (i // 3) * self.cad_block_width + bypass_adj, 1)) + if ((i + 1) / 3) == v1_bypass_block: + bypass_offset = selector_offsets[i] + self.cad_bypass_block_delta + + else: + # Generic Type-A MMU case + num_gates = int(round(length / self.cad_gate_width)) + 1 + adj_gate_width = length / (num_gates - 1) if num_gates > 1 else length + self.mmu.log_debug("Adjusted gate width: %.1f" % adj_gate_width) + for i in range(num_gates): + selector_offsets.append(round(gate0_pos + (i * adj_gate_width), 1)) + bypass_offset = bypass_pos + + if num_gates != self.mmu.num_gates: + self.mmu.log_error("You configued your MMU for %d gates but I counted %d! Please update 'num_gates'" % (self.mmu.num_gates, num_gates)) + return False + + self.mmu.log_always("Offsets: %s%s" % (selector_offsets, (" (bypass: %.1f)" % bypass_offset) if bypass_offset > 0 else " (no bypass fitted)")) + if save: + self.selector_offsets = selector_offsets + self.bypass_offset = bypass_offset + self.mmu.save_variable(self.VARS_MMU_SELECTOR_OFFSETS, self.selector_offsets) + self.mmu.save_variable(self.VARS_MMU_SELECTOR_BYPASS, self.bypass_offset) + self.mmu.write_variables() + self.mmu.log_always("Selector calibration has been saved") + return True + + def _home_selector(self): + self.mmu.unselect_gate() + self.filament_hold_move() + self.mmu.movequeues_wait() + try: + homing_state = mmu_machine.MmuHoming(self.mmu.printer, self.mmu_toolhead) + homing_state.set_axes([0]) + self.mmu.mmu_toolhead.get_kinematics().home(homing_state) + self.is_homed = True + except Exception as e: # Homing failed + raise MmuError("Homing selector failed because of blockage or malfunction. Klipper reports: %s" % str(e)) + + def _position(self, target): + if not self.use_touch_move(): + self.move("Positioning selector", target) + else: + init_pos = self.mmu_toolhead.get_position()[0] + halt_pos,homed = self.homing_move("Positioning selector with 'touch' move", target, homing_move=1, endstop_name=self.mmu.SENSOR_SELECTOR_TOUCH) + if homed: # Positioning move was not successful + with self.mmu.wrap_suppress_visual_log(): + travel = abs(init_pos - halt_pos) + if travel < 4.0: # Filament stuck in the current gate (based on ERCF design) + self.mmu.log_info("Selector is blocked by filament inside gate, will try to recover...") + self.move("Realigning selector by a distance of: %.1fmm" % -travel, init_pos) + self.mmu_toolhead.flush_step_generation() # TTC mitigation when homing move + regular + get_last_move_time() in close succession + + # See if we can detect filament in gate area + found = self.mmu.check_filament_in_gate() + if not found: + # Push filament into view of the gate endstop + self.filament_drive() + _,_,measured,_ = self.mmu.trace_filament_move("Locating filament", self.mmu.gate_parking_distance + self.mmu.gate_endstop_to_encoder + 10.) + if self.mmu.has_encoder() and measured < self.mmu.encoder_min: + raise MmuError("Unblocking selector failed bacause unable to move filament to clear") + + # Try a full unload sequence + try: + self.mmu.unload_sequence(check_state=True) + except MmuError as ee: + raise MmuError("Unblocking selector failed because: %s" % (str(ee))) + + # Check if selector can now reach proper target + self._home_selector() + halt_pos,homed = self.homing_move("Positioning selector with 'touch' move", target, homing_move=1, endstop_name=self.mmu.SENSOR_SELECTOR_TOUCH) + if homed: # Positioning move was not successful + self.is_homed = False + raise MmuError("Unblocking selector recovery failed. Path is probably internally blocked") + + else: # Selector path is blocked, probably externally + self.is_homed = False + raise MmuError("Selector is externally blocked perhaps by filament in another gate") + + def move(self, trace_str, new_pos, speed=None, accel=None, wait=False): + return self._trace_selector_move(trace_str, new_pos, speed=speed, accel=accel, wait=wait)[0] + + def homing_move(self, trace_str, new_pos, speed=None, accel=None, homing_move=0, endstop_name=None): + return self._trace_selector_move(trace_str, new_pos, speed=speed, accel=accel, homing_move=homing_move, endstop_name=endstop_name) + + # Internal raw wrapper around all selector moves except rail homing + # Returns position after move, if homed (homing moves) + def _trace_selector_move(self, trace_str, new_pos, speed=None, accel=None, homing_move=0, endstop_name=None, wait=False): + if trace_str: + self.mmu.log_trace(trace_str) + + self.mmu_toolhead.quiesce() + + # Set appropriate speeds and accel if not supplied + if homing_move != 0: + speed = speed or (self.selector_touch_speed if self.selector_touch_enabled or endstop_name == self.mmu.SENSOR_SELECTOR_TOUCH else self.selector_homing_speed) + else: + speed = speed or self.selector_move_speed + accel = accel or self.mmu_toolhead.get_selector_limits()[1] + + pos = self.mmu_toolhead.get_position() + homed = False + if homing_move != 0: + # Check for valid endstop + endstops = self.selector_rail.get_endstops() if endstop_name is None else self.selector_rail.get_extra_endstop(endstop_name) + if endstops is None: + self.mmu.log_error("Endstop '%s' not found" % endstop_name) + return pos[0], homed + + hmove = HomingMove(self.mmu.printer, endstops, self.mmu_toolhead) + try: + trig_pos = [0., 0., 0., 0.] + with self.mmu.wrap_accel(accel): + pos[0] = new_pos + trig_pos = hmove.homing_move(pos, speed, probe_pos=True, triggered=homing_move > 0, check_triggered=True) + if hmove.check_no_movement(): + self.mmu.log_stepper("No movement detected") + if self.selector_rail.is_endstop_virtual(endstop_name): + # Try to infer move completion is using Stallguard. Note that too slow speed or accelaration + delta = abs(new_pos - trig_pos[0]) + if delta < 1.0: + homed = False + self.mmu.log_trace("Truing selector %.4fmm to %.2fmm" % (delta, new_pos)) + self.mmu_toolhead.move(pos, speed) + else: + homed = True + else: + homed = True + except self.mmu.printer.command_error: + homed = False + finally: + self.mmu_toolhead.flush_step_generation() # TTC mitigation when homing move + regular + get_last_move_time() in close succession + pos = self.mmu_toolhead.get_position() + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR HOMING MOVE: requested position=%.1f, speed=%.1f, accel=%.1f, endstop_name=%s >> %s" % (new_pos, speed, accel, endstop_name, "%s actual pos=%.2f, trig_pos=%.2f" % ("HOMED" if homed else "DID NOT HOMED", pos[0], trig_pos[0]))) + else: + pos = self.mmu_toolhead.get_position() + with self.mmu.wrap_accel(accel): + pos[0] = new_pos + self.mmu_toolhead.move(pos, speed) + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR MOVE: position=%.1f, speed=%.1f, accel=%.1f" % (new_pos, speed, accel)) + if wait: + self.mmu.movequeues_wait(toolhead=False, mmu_toolhead=True) + + return pos[0], homed + + def set_position(self, position): + pos = self.mmu_toolhead.get_position() + pos[0] = position + self.mmu_toolhead.set_position(pos, homing_axes=(0,)) + self.enable_motors() + self.is_homed = True + return position + + def measure_to_home(self): + self.mmu.movequeues_wait() + init_mcu_pos = self.selector_stepper.get_mcu_position() + homed = False + try: + homing_state = mmu_machine.MmuHoming(self.mmu.printer, self.mmu_toolhead) + homing_state.set_axes([0]) + self.mmu_toolhead.get_kinematics().home(homing_state) + homed = True + except Exception: + pass # Home not found + mcu_position = self.selector_stepper.get_mcu_position() + traveled = abs(mcu_position - init_mcu_pos) * self.selector_stepper.get_step_dist() + return traveled, homed + + def use_touch_move(self): + return self.selector_tmc and self.mmu.SENSOR_SELECTOR_TOUCH in self.selector_rail.get_extra_endstop_names() and self.selector_touch_enabled + + + +################################################################################ +# LinearSelectorServo +# Implements servo control for typical linear selector +################################################################################ + +class LinearSelectorServo: + + # mmu_vars.cfg variables + VARS_MMU_SERVO_ANGLES = "mmu_servo_angles" + + def __init__(self, mmu): + self.mmu = mmu + + # Servo states + self.SERVO_MOVE_STATE = mmu.FILAMENT_HOLD_STATE + self.SERVO_DOWN_STATE = mmu.FILAMENT_DRIVE_STATE + self.SERVO_UP_STATE = mmu.FILAMENT_RELEASE_STATE + self.SERVO_UNKNOWN_STATE = mmu.FILAMENT_UNKNOWN_STATE + + # Process config + self.servo_angles = {} + self.servo_angles['down'] = mmu.config.getint('servo_down_angle', 90) + self.servo_angles['up'] = mmu.config.getint('servo_up_angle', 90) + self.servo_angles['move'] = mmu.config.getint('servo_move_angle', self.servo_angles['up']) + self.servo_duration = mmu.config.getfloat('servo_duration', 0.2, minval=0.1) + self.servo_always_active = mmu.config.getint('servo_always_active', 0, minval=0, maxval=1) + self.servo_active_down = mmu.config.getint('servo_active_down', 0, minval=0, maxval=1) + self.servo_dwell = mmu.config.getfloat('servo_dwell', 0.4, minval=0.1) + self.servo_buzz_gear_on_down = mmu.config.getint('servo_buzz_gear_on_down', 3, minval=0, maxval=10) + + self.servo = mmu.printer.lookup_object('mmu_servo selector_servo', None) + if not self.servo: + raise mmu.config.error("No [mmu_servo selector_servo] definition found in mmu_hardware.cfg") + + # Register GCODE commands specific to this module + gcode = self.mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_SERVO', self.cmd_MMU_SERVO, desc = self.cmd_MMU_SERVO_help) + + self.reinit() + + def reinit(self): + self.servo_state = self.SERVO_UNKNOWN_STATE + self.servo_angle = self.SERVO_UNKNOWN_STATE + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + + # Override with saved/calibrated servo positions (set with MMU_SERVO) + try: + servo_angles = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SERVO_ANGLES, {}) + self.servo_angles.update(servo_angles) + except Exception as e: + raise self.mmu.config.error("Exception whilst parsing servo angles from 'mmu_vars.cfg': %s" % str(e)) + + def handle_disconnect(self): + pass + + def handle_ready(self): + pass + + cmd_MMU_SERVO_help = "Move MMU servo to position specified position or angle" + def cmd_MMU_SERVO(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + reset = gcmd.get_int('RESET', 0) + save = gcmd.get_int('SAVE', 0) + pos = gcmd.get('POS', "").lower() + if reset: + self.mmu.delete_variable(self.VARS_MMU_SERVO_ANGLES, write=True) + self.mmu.log_info("Calibrated servo angles have be reset to configured defaults") + elif pos == "off": + self.servo_off() # For 'servo_always_active' case + elif pos == "up": + if save: + self._servo_save_pos(pos) + else: + self.servo_up() + elif pos == "move": + if save: + self._servo_save_pos(pos) + else: + self.servo_move() + elif pos == "down": + if self.mmu.check_if_bypass(): return + if save: + self._servo_save_pos(pos) + else: + self.servo_down() + elif save: + self.mmu.log_error("Servo position not specified for save") + elif pos == "": + if self.mmu.check_if_bypass(): return + angle = gcmd.get_int('ANGLE', None) + if angle is not None: + self.mmu.log_debug("Setting servo to angle: %d" % angle) + self._set_servo_angle(angle) + else: + self.mmu.log_always("Current servo angle: %d, Positions: %s" % (self.servo_angle, self.servo_angles)) + self.mmu.log_info("Use POS= or ANGLE= to move position") + else: + self.mmu.log_error("Unknown servo position '%s'" % pos) + + def _set_servo_angle(self, angle): + self.servo.set_position(angle=angle, duration=None if self.servo_always_active else self.servo_duration) + self.servo_angle = angle + self.servo_state = self.SERVO_UNKNOWN_STATE + + def _servo_save_pos(self, pos): + if self.servo_angle != self.SERVO_UNKNOWN_STATE: + self.servo_angles[pos] = self.servo_angle + self.mmu.save_variable(self.VARS_MMU_SERVO_ANGLES, self.servo_angles, write=True) + self.mmu.log_info("Servo angle '%d' for position '%s' has been saved" % (self.servo_angle, pos)) + else: + self.mmu.log_info("Servo angle unknown") + + def servo_down(self, buzz_gear=True): + if self.mmu._is_running_test: return # Save servo while testing + if self.mmu.gate_selected == self.mmu.TOOL_GATE_BYPASS: return + if self.servo_state == self.SERVO_DOWN_STATE: return + self.mmu.log_trace("Setting servo to down (filament drive) position at angle: %d" % self.servo_angles['down']) + + if buzz_gear and self.servo_buzz_gear_on_down > 0: + self.mmu_toolhead.sync(MmuToolHead.GEAR_ONLY) # Must be in correct sync mode before buzz to avoid delay + + self.mmu.movequeues_wait() # Probably not necessary + initial_encoder_position = self.mmu.get_encoder_distance(dwell=None) + self.servo.set_position(angle=self.servo_angles['down'], duration=None if self.servo_active_down or self.servo_always_active else self.servo_duration) + + if self.servo_angle != self.servo_angles['down'] and buzz_gear and self.servo_buzz_gear_on_down > 0: + for _ in range(self.servo_buzz_gear_on_down): + self.mmu.trace_filament_move(None, 0.8, speed=25, accel=self.mmu.gear_buzz_accel, encoder_dwell=None, speed_override=False) + self.mmu.trace_filament_move(None, -0.8, speed=25, accel=self.mmu.gear_buzz_accel, encoder_dwell=None, speed_override=False) + self.mmu.movequeues_dwell(max(self.servo_dwell, self.servo_duration, 0)) + + self.servo_angle = self.servo_angles['down'] + self.servo_state = self.SERVO_DOWN_STATE + self.mmu.set_encoder_distance(initial_encoder_position) + self.mmu.mmu_macro_event(self.mmu.MACRO_EVENT_FILAMENT_GRIPPED) + + def servo_move(self): # Position servo for selector movement + if self.mmu._is_running_test: return # Save servo while testing + if self.servo_state == self.SERVO_MOVE_STATE: return + self.mmu.log_trace("Setting servo to move (filament hold) position at angle: %d" % self.servo_angles['move']) + if self.servo_angle != self.servo_angles['move']: + self.mmu.movequeues_wait() + self.servo.set_position(angle=self.servo_angles['move'], duration=None if self.servo_always_active else self.servo_duration) + self.mmu.movequeues_dwell(max(self.servo_dwell, self.servo_duration, 0)) + self.servo_angle = self.servo_angles['move'] + self.servo_state = self.SERVO_MOVE_STATE + + def servo_up(self, measure=False): + if self.mmu._is_running_test: return 0. # Save servo while testing + if self.servo_state == self.SERVO_UP_STATE: return 0. + self.mmu.log_trace("Setting servo to up (filament released) position at angle: %d" % self.servo_angles['up']) + delta = 0. + if self.servo_angle != self.servo_angles['up']: + self.mmu.movequeues_wait() + if measure: + initial_encoder_position = self.mmu.get_encoder_distance(dwell=None) + self.servo.set_position(angle=self.servo_angles['up'], duration=None if self.servo_always_active else self.servo_duration) + self.mmu.movequeues_dwell(max(self.servo_dwell, self.servo_duration, 0)) + if measure: + # Report on spring back in filament then revert counter + delta = self.mmu.get_encoder_distance() - initial_encoder_position + if delta > 0.: + self.mmu.log_debug("Spring in filament measured %.1fmm - adjusting encoder" % delta) + self.mmu.set_encoder_distance(initial_encoder_position, dwell=None) + self.servo_angle = self.servo_angles['up'] + self.servo_state = self.SERVO_UP_STATE + return delta + + def _servo_auto(self): + if self.mmu.is_printing() and self.mmu_toolhead.is_gear_synced_to_extruder(): + self.servo_down() + elif not self.mmu.selector.is_homed or self.mmu.tool_selected < 0 or self.mmu.gate_selected < 0: + self.servo_move() + else: + self.servo_up() + + # De-energize servo if 'servo_always_active' or 'servo_active_down' are being used + def servo_off(self): + self.servo.set_position(width=0, duration=None) + + def get_filament_grip_state(self): + return self.servo_state + + def disable_motors(self): + self.servo_move() + self.servo_off() + self.reinit() # Reset state + + def enable_motors(self): + self.servo_move() + + def buzz_motor(self): + self.mmu.movequeues_wait() + old_state = self.servo_state + low=min(self.servo_angles['down'], self.servo_angles['up']) + high=max(self.servo_angles['down'], self.servo_angles['up']) + mid = (low + high) / 2 + move = (high - low) / 4 + duration=None if self.servo_always_active else self.servo_duration + self.servo.set_position(angle=mid, duration=duration) + self.mmu.movequeues_dwell(max(self.servo_duration, 0.5), mmu_toolhead=False) + self.servo.set_position(angle=(mid - move), duration=duration) + self.mmu.movequeues_dwell(max(self.servo_duration, 0.5), mmu_toolhead=False) + self.servo.set_position(angle=(mid + move), duration=duration) + self.mmu.movequeues_dwell(max(self.servo_duration, 0.5), mmu_toolhead=False) + self.mmu.movequeues_wait() + if old_state == self.SERVO_DOWN_STATE: + self.servo_down(buzz_gear=False) + elif old_state == self.SERVO_MOVE_STATE: + self.servo_move() + else: + self.servo_up() + + def set_test_config(self, gcmd): + self.servo_duration = gcmd.get_float('SERVO_DURATION', self.servo_duration, minval=0.1) + self.servo_always_active = gcmd.get_int('SERVO_ALWAYS_ACTIVE', self.servo_always_active, minval=0, maxval=1) + self.servo_active_down = gcmd.get_int('SERVO_ACTIVE_DOWN', self.servo_active_down, minval=0, maxval=1) + self.servo_dwell = gcmd.get_float('SERVO_DWELL', self.servo_active_down, minval=0.1) + self.servo_buzz_gear_on_down = gcmd.get_int('SERVO_BUZZ_GEAR_ON_DOWN', self.servo_buzz_gear_on_down, minval=0, maxval=10) + + def get_test_config(self): + msg = "\n\nSERVO:" + msg += "\nservo_duration = %.1f" % self.servo_duration + msg += "\nservo_always_active = %d" % self.servo_always_active + msg += "\nservo_active_down = %d" % self.servo_active_down + msg += "\nservo_dwell = %.1f" % self.servo_dwell + msg += "\nservo_buzz_gear_on_down = %d" % self.servo_buzz_gear_on_down + + return msg + + def check_test_config(self, param): + return vars(self).get(param) is None + + def get_mmu_status_config(self): + msg = ". Servo in %s position" % ("UP" if self.servo_state == self.SERVO_UP_STATE else \ + "DOWN" if self.servo_state == self.SERVO_DOWN_STATE else "MOVE" if self.servo_state == self.SERVO_MOVE_STATE else "unknown") + return msg + + def get_status(self, eventtime): + return { + 'servo': "Up" if self.servo_state == self.SERVO_UP_STATE else + "Down" if self.servo_state == self.SERVO_DOWN_STATE else + "Move" if self.servo_state == self.SERVO_MOVE_STATE else + "Unknown", + } + + + +################################################################################ +# LinearServoSelector: +# Implements Linear Selector for type-A MMU's with servo +# - Stepper controlled linear movement with endstop +# - Servo controlled filament gripping +# - Supports type-A with combined selection and filament gripping line ERCFv3 +# +class LinearServoSelector(LinearSelector, object): + + def __init__(self, mmu): + super(LinearServoSelector, self).__init__(mmu) + + + +################################################################################ +# LinearMultiGearSelector: +# Implements Linear Selector for type-C MMU's that: +# - Uses gear driver stepper gate +# - Uses selector stepper for gate selection with endstop +# - Supports type-A classic MMU's like ERCF and Tradrack +# +################################################################################ + +class LinearMultiGearSelector(LinearSelector, object): + + def __init__(self, mmu): + super(LinearMultiGearSelector, self).__init__(mmu) + + # Selector "Interface" methods --------------------------------------------- + + def select_gate(self, gate): + self.mmu_toolhead.select_gear_stepper(gate) # Select correct gear drive stepper or none if bypass + super(LinearMultiGearSelector, self).select_gate(gate) + + def restore_gate(self, gate): + self.mmu.mmu_toolhead.select_gear_stepper(gate) # Select correct gear drive stepper or none if bypass + super(LinearMultiGearSelector, self).restore_gate(gate) + + + +################################################################################ +# Rotary Selector +# Implements Rotary Selector for type-A MMU's that uses stepper controlled +# rail[0] on mmu toolhead (3D Chameleon) +# +# 'filament_always_gripped' alters operation: +# 0 (default) - Lazy gate selection, occurs when asked to grip filament +# 1 - Gripped immediately on selection and will not release +# +# Implements commands: +# MMU_CALIBRATE_SELECTOR +# MMU_SOAKTEST_SELECTOR +# MMU_GRIP - realign with selected gate +# MMU_RELEASE - move between gates to release filament +################################################################################ + +class RotarySelector(BaseSelector, object): + + # mmu_vars.cfg variables + VARS_MMU_SELECTOR_OFFSETS = "mmu_selector_offsets" + VARS_MMU_SELECTOR_GATE_POS = "mmu_selector_gate_pos" + + def __init__(self, mmu): + super(RotarySelector, self).__init__(mmu) + + # Process config + self.selector_move_speed = mmu.config.getfloat('selector_move_speed', 200, minval=1.) + self.selector_homing_speed = mmu.config.getfloat('selector_homing_speed', 100, minval=1.) + self.selector_touch_speed = mmu.config.getfloat('selector_touch_speed', 60, minval=1.) # Not used with 3DChameleon but allows for param in config + self.selector_touch_enabled = mmu.config.getint('selector_touch_enabled', 1, minval=0, maxval=1) # Not used with 3DChameleon but allows for param in config + + # To simplfy config CAD related parameters are set based on vendor and version setting + # + # cad_gate0_pos - approximate distance from endstop to first gate + # cad_gate_width - width of each gate + # cad_last_gate_offset - distance from end of travel to last gate + # + # Chameleon defaults + self.cad_gate0_pos = 4.0 + self.cad_gate_width = 25. + self.cad_last_gate_offset = 2. + self.cad_bypass_offset = 0 # Doesn't have bypass + self.cad_selector_tolerance = 15. + + self.cad_gate_directions = [1, 1, 0, 0] + self.cad_release_gates = [2, 3, 0, 1] + + # But still allow all CAD parameters to be customized + self.cad_gate0_pos = mmu.config.getfloat('cad_gate0_pos', self.cad_gate0_pos, minval=0.) + self.cad_gate_width = mmu.config.getfloat('cad_gate_width', self.cad_gate_width, above=0.) + self.cad_last_gate_offset = mmu.config.getfloat('cad_last_gate_offset', self.cad_last_gate_offset, above=0.) + self.cad_selector_tolerance = mmu.config.getfloat('cad_selector_tolerance', self.cad_selector_tolerance, above=0.) # Extra movement allowed by selector + + self.cad_gate_directions = list(mmu.config.getintlist('cad_gate_directions',self.cad_gate_directions)) + self.cad_release_gates = list(mmu.config.getintlist('cad_release_gates', self.cad_release_gates)) + + # Register GCODE commands specific to this module + gcode = mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_CALIBRATE_SELECTOR', self.cmd_MMU_CALIBRATE_SELECTOR, desc=self.cmd_MMU_CALIBRATE_SELECTOR_help) + gcode.register_command('MMU_SOAKTEST_SELECTOR', self.cmd_MMU_SOAKTEST_SELECTOR, desc=self.cmd_MMU_SOAKTEST_SELECTOR_help) + gcode.register_command('MMU_GRIP', self.cmd_MMU_GRIP, desc=self.cmd_MMU_GRIP_help) + gcode.register_command('MMU_RELEASE', self.cmd_MMU_RELEASE, desc=self.cmd_MMU_RELEASE_help) + + # Selector stepper setup before MMU toolhead is instantiated + section = mmu_machine.SELECTOR_STEPPER_CONFIG + if mmu.config.has_section(section): + # Inject options into selector stepper config regardless or what user sets + mmu.config.fileconfig.set(section, 'position_min', -1.) + mmu.config.fileconfig.set(section, 'position_max', self._get_max_selector_movement()) + mmu.config.fileconfig.set(section, 'homing_speed', self.selector_homing_speed) + + # Selector "Interface" methods --------------------------------------------- + + def reinit(self): + self.grip_state = self.mmu.FILAMENT_DRIVE_STATE + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.selector_rail = self.mmu_toolhead.get_kinematics().rails[0] + self.selector_stepper = self.selector_rail.steppers[0] + + # Have an endstop (most likely stallguard)? + endstops = self.selector_rail.get_endstops() + self.has_endstop = bool(endstops) and endstops[0][0].__class__.__name__ != "MockEndstop" + + # Load selector offsets (calibration set with MMU_CALIBRATE_SELECTOR) ------------------------------- + self.selector_offsets = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_OFFSETS, None) + if self.selector_offsets: + # Ensure list size + if len(self.selector_offsets) == self.mmu.num_gates: + self.mmu.log_debug("Loaded saved selector offsets: %s" % self.selector_offsets) + else: + self.mmu.log_error("Incorrect number of gates specified in %s. Adjusted length" % self.VARS_MMU_SELECTOR_OFFSETS) + self.selector_offsets = self._ensure_list_size(self.selector_offsets, self.mmu.num_gates) + + if not any(x == -1 for x in self.selector_offsets): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + else: + self.mmu.log_always("Warning: Selector offsets not found in mmu_vars.cfg. Probably not calibrated") + self.selector_offsets = [-1] * self.mmu.num_gates + self.mmu.save_variables.allVariables[self.VARS_MMU_SELECTOR_OFFSETS] = self.selector_offsets + + def _ensure_list_size(self, lst, size, default_value=-1): + lst = lst[:size] + lst.extend([default_value] * (size - len(lst))) + return lst + + def home(self, force_unload = None): + if self.mmu.check_if_bypass(): return + with self.mmu.wrap_action(self.mmu.ACTION_HOMING): + self.mmu.log_info("Homing MMU...") + if force_unload is not None: + self.mmu.log_debug("(asked to %s)" % ("force unload" if force_unload else "not unload")) + if force_unload is True: + # Forced unload case for recovery + self.mmu.unload_sequence(check_state=True) + elif force_unload is False and self.mmu.filament_pos != self.mmu.FILAMENT_POS_UNLOADED: + # Automatic unload case + self.mmu.unload_sequence() + self._home_selector() + + # Actual gate selection can be delayed (if not forcing grip) until the + # filament_drive/release to reduce selector movement + def select_gate(self, gate): + if gate != self.mmu.gate_selected: + with self.mmu.wrap_action(self.mmu.ACTION_SELECTING): + if self.mmu.mmu_machine.filament_always_gripped: + self._grip(gate) + + def restore_gate(self, gate): + gate_pos = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_GATE_POS, None) + if gate_pos is not None: + self.set_position(self.selector_offsets[gate_pos]) + if gate == gate_pos: + self.grip_state = self.mmu.FILAMENT_DRIVE_STATE + else: + self.grip_state = self.mmu.FILAMENT_RELEASE_STATE + else: + self.grip_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def filament_drive(self): + self._grip(self.mmu.gate_selected) + + def filament_release(self, measure=False): + if not self.mmu.mmu_machine.filament_always_gripped: + self._grip(self.mmu.gate_selected, release=True) + return 0. # Fake encoder movement + + # Note there is no separation of gate selection and grip/release with this type of selector + def _grip(self, gate, release=False): + if gate >= 0: + if release: + release_pos = self.selector_offsets[self.cad_release_gates[gate]] + self.mmu.log_trace("Setting selector to filament release position at position: %.1f" % release_pos) + self._position(release_pos) + self.grip_state = self.mmu.FILAMENT_RELEASE_STATE + + # Precaution to ensure correct postion/gate restoration on restart + self.mmu.save_variable(self.VARS_MMU_SELECTOR_GATE_POS, self.cad_release_gates[gate], write=True) + else: + grip_pos = self.selector_offsets[gate] + self.mmu.log_trace("Setting selector to filament grip position at position: %.1f" % grip_pos) + self._position(grip_pos) + self.grip_state = self.mmu.FILAMENT_DRIVE_STATE + + # Precaution to ensure correct postion/gate restoration on restart + self.mmu.save_variable(self.VARS_MMU_SELECTOR_GATE_POS, gate, write=True) + + # Ensure gate filament drive is in the correct direction + self.mmu_toolhead.get_kinematics().rails[1].set_direction(self.cad_gate_directions[gate]) + self.mmu.movequeues_wait() + else: + self.grip_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def get_filament_grip_state(self): + return self.grip_state + + def disable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_disable(self.mmu_toolhead.get_last_move_time()) + self.is_homed = False + + def enable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_enable(self.mmu_toolhead.get_last_move_time()) + + def buzz_motor(self, motor): + if motor == "selector": + pos = self.mmu_toolhead.get_position()[0] + self.move(None, pos + 5, wait=False) + self.move(None, pos - 5, wait=False) + self.move(None, pos, wait=False) + else: + return False + return True + + def get_status(self, eventtime): + status = super(RotarySelector, self).get_status(eventtime) + status.update({ + 'grip': "Gripped" if self.grip_state == self.mmu.FILAMENT_DRIVE_STATE else "Released", + }) + return status + + def get_mmu_status_config(self): + msg = "\nSelector is NOT HOMED. " if not self.is_homed else "" + msg += "Filament is %s" % ("GRIPPED" if self.grip_state == self.mmu.FILAMENT_DRIVE_STATE else "RELEASED") + return msg + + def set_test_config(self, gcmd): + self.selector_move_speed = gcmd.get_float('SELECTOR_MOVE_SPEED', self.selector_move_speed, minval=1.) + self.selector_homing_speed = gcmd.get_float('SELECTOR_HOMING_SPEED', self.selector_homing_speed, minval=1.) + + def get_test_config(self): + msg = "\n\nSELECTOR:" + msg += "\nselector_move_speed = %.1f" % self.selector_move_speed + msg += "\nselector_homing_speed = %.1f" % self.selector_homing_speed + return msg + + def get_uncalibrated_gates(self, check_gates): + return [gate for gate, value in enumerate(self.selector_offsets) if value == -1 and gate in check_gates] + + # Internal Implementation -------------------------------------------------- + + cmd_MMU_GRIP_help = "Grip filament in current gate" + def cmd_MMU_GRIP(self, gcmd): + if self.mmu.gate_selected >= 0: + self.filament_drive() + + cmd_MMU_RELEASE_help = "Ungrip filament in current gate" + def cmd_MMU_RELEASE(self, gcmd): + if self.mmu.gate_selected >= 0: + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_release() + else: + self.mmu.log_error("Selector configured to not allow filament release") + + cmd_MMU_CALIBRATE_SELECTOR_help = "Calibration of the selector positions or postion of specified gate" + def cmd_MMU_CALIBRATE_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + single = gcmd.get_int('SINGLE', 0, minval=0, maxval=1) + quick = gcmd.get_int('QUICK', 0, minval=0, maxval=1) + gate = gcmd.get_int('GATE', 0, minval=0, maxval=self.mmu.mmu_machine.num_gates - 1) + + try: + self.mmu.calibrating = True + self.mmu.reinit() + successful = False + + if self.has_endstop and not quick: + successful = self._calibrate_selector(gate, extrapolate=not single, save=save) + else: + self.mmu.log_always("%s - will calculate gate offsets from cad_gate0_offset and cad_gate_width" % ("Quick method" if quick else "No endstop configured")) + self.selector_offsets = [round(self.cad_gate0_pos + i * self.cad_gate_width, 1) for i in range(self.mmu.num_gates)] + self.mmu.save_variable(self.VARS_MMU_SELECTOR_OFFSETS, self.selector_offsets, write=True) + successful = True + + if not any(x == -1 for x in self.selector_offsets): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + + # If not fully calibrated turn off the selector stepper to ease next step, else activate by homing + if successful and self.mmu.calibration_status & self.mmu.CALIBRATED_SELECTOR: + self.mmu.log_always("Selector calibration complete") + self.mmu.select_tool(0) + else: + self.mmu.motors_onoff(on=False, motor="selector") + + except MmuError as ee: + self.mmu.handle_mmu_error(str(ee)) + finally: + self.mmu.calibrating = False + + cmd_MMU_SOAKTEST_SELECTOR_help = "Soak test of selector movement" + def cmd_MMU_SOAKTEST_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_loaded(): return + if self.mmu.check_if_not_calibrated(self.mmu.CALIBRATED_SELECTOR): return + loops = gcmd.get_int('LOOP', 100) + home = bool(gcmd.get_int('HOME', 0)) + try: + with self.mmu.wrap_sync_gear_to_extruder(): + if home: + self.home() + for l in range(loops): + self.mmu.log_always("Testing loop %d / %d" % (l + 1, loops)) + tool = random.randint(0, self.mmu.num_gates - 1) + if random.randint(0, 10) == 0 and home: + self.mmu.home(tool=tool) + else: + if random.randint(0, 10) == 0 and home: + self.mmu.home(tool=tool) + else: + self.mmu.select_tool(tool) + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_drive() + except MmuError as ee: + self.mmu.handle_mmu_error("Soaktest abandoned because of error: %s" % str(ee)) + + def _get_max_selector_movement(self, gate=-1): + n = gate if gate >= 0 else self.mmu.num_gates - 1 + + max_movement = self.cad_gate0_pos + (n * self.cad_gate_width) + max_movement += self.cad_last_gate_offset if gate in [self.mmu.TOOL_GATE_UNKNOWN] else 0. + max_movement += self.cad_selector_tolerance + return max_movement + + # Manual selector offset calibration + def _calibrate_selector(self, gate, extrapolate=True, save=True): + max_movement = self._get_max_selector_movement(gate) + self.mmu.log_always("Measuring the selector position for gate %d..." % gate) + traveled, found_home = self.measure_to_home() + + # Test we actually homed + if not found_home: + self.mmu.log_error("Selector didn't find home position") + return False + + # Warn and don't save if the measurement is unexpected + if traveled > max_movement: + self.mmu.log_always("Selector move measured %.1fmm. More than the anticipated maximum of %.1fmm. Save disabled\nIt is likely that your basic MMU dimensions are incorrect in mmu_parameters.cfg. Check vendor/version and optional 'cad_*' parameters" % (traveled, max_movement)) + save = 0 + else: + self.mmu.log_always("Selector move measured %.1fmm" % traveled) + + if save: + self.selector_offsets[gate] = round(traveled, 1) + if extrapolate and gate == self.mmu.num_gates - 1 and self.selector_offsets[0] > 0: + # Distribute selector spacing based on measurements of first and last gate + spacing = (self.selector_offsets[-1] - self.selector_offsets[0]) / (self.mmu.num_gates - 1) + self.selector_offsets = [round(self.selector_offsets[0] + i * spacing, 1) for i in range(self.mmu.num_gates)] + elif extrapolate: + # Distribute using cad spacing + self.selector_offsets = [round(self.selector_offsets[0] + i * self.cad_gate_width, 1) for i in range(self.mmu.num_gates)] + else: + extrapolate = False + self.mmu.save_variable(self.VARS_MMU_SELECTOR_OFFSETS, self.selector_offsets, write=True) + + if extrapolate: + self.mmu.log_always("All selector offsets have been extrapolated and saved:\n%s" % self.selector_offsets) + else: + self.mmu.log_always("Selector offset (%.1fmm) for gate %d has been saved" % (traveled, gate)) + if gate == 0: + self.mmu.log_always("Run MMU_CALIBRATE_SELECTOR again with GATE=%d to extrapolate all gate positions. Use SINGLE=1 to force calibration of only one gate" % (self.mmu.num_gates - 1)) + return True + + def _home_selector(self): + self.mmu.unselect_gate() + self.mmu.movequeues_wait() + try: + if self.has_endstop: + homing_state = mmu_machine.MmuHoming(self.mmu.printer, self.mmu_toolhead) + homing_state.set_axes([0]) + self.mmu.mmu_toolhead.get_kinematics().home(homing_state) + else: + self._home_hard_endstop() + self.is_homed = True + except Exception as e: # Homing failed + raise MmuError("Homing selector failed because of blockage or malfunction. Klipper reports: %s" % str(e)) + + def _home_hard_endstop(self): + self.mmu.log_always("Forcing selector homing to hard endstop. Excuse the noise!\n(Configure stallguard endstop on selector stepper to avoid)") + self.set_position(self._get_max_selector_movement()) # Worst case position to allow full movement + self.move("Forceably homing to hard endstop", new_pos=0, speed=self.selector_homing_speed) + self.set_position(0) # Reset pos + + def _position(self, target): + self.move("Positioning selector", target) + + def move(self, trace_str, new_pos, speed=None, accel=None, wait=False): + return self._trace_selector_move(trace_str, new_pos, speed=speed, accel=accel, wait=wait) + + # Internal raw wrapper around all selector moves except rail homing + # Returns position after move, if homed (homing moves) + def _trace_selector_move(self, trace_str, new_pos, speed=None, accel=None, wait=False): + if trace_str: + self.mmu.log_trace(trace_str) + + self.mmu_toolhead.quiesce() + + # Set appropriate speeds and accel if not supplied + speed = speed or self.selector_move_speed + accel = accel or self.mmu_toolhead.get_selector_limits()[1] + + pos = self.mmu_toolhead.get_position() + with self.mmu.wrap_accel(accel): + pos[0] = new_pos + self.mmu_toolhead.move(pos, speed) + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR MOVE: position=%.1f, speed=%.1f, accel=%.1f" % (new_pos, speed, accel)) + if wait: + self.mmu.movequeues_wait(toolhead=False, mmu_toolhead=True) + return pos[0] + + def set_position(self, position): + pos = self.mmu_toolhead.get_position() + pos[0] = position + self.mmu_toolhead.set_position(pos, homing_axes=(0,)) + self.enable_motors() + self.is_homed = True + return position + + def measure_to_home(self): + self.mmu.movequeues_wait() + init_mcu_pos = self.selector_stepper.get_mcu_position() + homed = False + try: + homing_state = mmu_machine.MmuHoming(self.mmu.printer, self.mmu_toolhead) + homing_state.set_axes([0]) + self.mmu_toolhead.get_kinematics().home(homing_state) + homed = True + except Exception: + pass # Home not found + mcu_position = self.selector_stepper.get_mcu_position() + traveled = abs(mcu_position - init_mcu_pos) * self.selector_stepper.get_step_dist() + return traveled, homed + + + +################################################################################ +# Macro Selector +# Implements macro-based selector for MMU's +# +# Example demultiplexer-style SELECT_TOOL macro: +# [gcode_macro SELECT_TOOL] +# gcode: +# SET_PIN PIN=d0 VALUE={params.S0} +# SET_PIN PIN=d1 VALUE={params.S1} +# SET_PIN PIN=d2 VALUE={params.S2} +# +# Example optocoupler-style SELECT_TOOL macro: +# [gcode_macro SELECT_TOOL] +# gcode: +# SET_PIN PIN=o{printer.mmu.gate} VALUE=0 +# SET_PIN PIN=o{params.GATE} VALUE=1 +################################################################################ + +class MacroSelector(BaseSelector, object): + + def __init__(self, mmu): + super(MacroSelector, self).__init__(mmu) + self.is_homed = True + + self.printer = mmu.printer + self.gcode = self.printer.lookup_object('gcode') + + self.select_tool_macro = mmu.config.get('select_tool_macro') + self.select_tool_num_switches = mmu.config.getint('select_tool_num_switches', default=0, minval=1) + + # Check if using a demultiplexer-style setup + if self.select_tool_num_switches > 0: + self.binary_mode = True + max_num_tools = 2**self.select_tool_num_switches + # Verify that there aren't too many tools for the demultiplexer + if mmu.num_gates > max_num_tools: + raise mmu.config.error('Maximum number of allowed tools is %d, but %d are present.' % (max_num_tools, mmu.num_gates)) + else: + self.binary_mode = False + + # Read all controller parameters related to selector or servo to stop klipper complaining. This + # is done to allow for uniform and shared mmu_parameters.cfg file regardless of configuration. + for option in ['selector_', 'servo_', 'cad_']: + for key in mmu.config.get_prefix_options(option): + _ = mmu.config.get(key) + + # Selector "Interface" methods --------------------------------------------- + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR # No calibration necessary + + def handle_ready(self): + logging.info("Happy Hare MacroSelector: Gate %d" % self.mmu.gate_selected) + self.select_gate(self.mmu.gate_selected) + + def select_gate(self, gate): + # Store parameters as list + params = ['GATE=' + str(gate)] + if self.binary_mode: # If demultiplexer, pass binary parameters to the macro in the form of S0=, S1=, S2=, etc. + binary = list(reversed('{0:b}'.format(gate).zfill(self.select_tool_num_switches))) + for i in range(self.select_tool_num_switches): + char = binary[i] + params.append('S' + str(i) + '=' + str(char)) + params = ' '.join(params) + + # Call selector macro + self.mmu.wrap_gcode_command('%s %s' % (self.select_tool_macro, params)) + + def restore_gate(self, gate): + pass + + + +################################################################################ +# Servo Selector +# Implements Servo based Selector for type-A MMU's like PicoMMU. Filament is +# always gripped when gate selected but a release position is assumed between +# each gate position (or specified release position, often 0 degrees) +# +# 'filament_always_gripped' alters operation: +# 0 (default) - Lazy gate selection, occurs when asked to grip filament +# 1 - Gripped immediately on selection and will not release +# +# Implements commands: +# MMU_CALIBRATE_SELECTOR +# MMU_SOAKTEST_SELECTOR +# MMU_GRIP - realign with selected gate +# MMU_RELEASE - move between gates to release filament +################################################################################ + +class ServoSelector(BaseSelector, object): + + # mmu_vars.cfg variables + VARS_MMU_SELECTOR_ANGLES = "mmu_selector_angles" + VARS_MMU_SELECTOR_BYPASS_ANGLE = "mmu_selector_bypass_angle" + + def __init__(self, mmu): + + super(ServoSelector, self).__init__(mmu) + self.is_homed = True + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + self.selector_bypass_angle = -1 + + # Get hardware + servo_name = mmu.config.get('selector_servo_name', "selector_servo") + self.servo = mmu.printer.lookup_object("mmu_servo %s" % servo_name, None) + if not self.servo: + raise self.mmu.config.error("Selector servo not found. Perhaps missing '[mmu_servo %s]' definition" % servo_name) + + # Process config + self.servo_duration = mmu.config.getfloat('servo_duration', 0.5, minval=0.1) + self.servo_dwell = mmu.config.getfloat('servo_dwell', 0.6, minval=0.1) + self.servo_always_active = mmu.config.getint('servo_always_active', 0, minval=0, maxval=1) + self.servo_min_angle = mmu.config.getfloat('servo_min_angle', 0, above=0) # Not exposed + self.servo_max_angle = mmu.config.getfloat('servo_max_angle', self.servo.max_angle, above=0) # Not exposed + self.servo_angle = self.servo_min_angle + (self.servo_max_angle - self.servo_min_angle) / 2 + self.selector_release_angle = mmu.config.getfloat('selector_release_angle', -1, minval=-1, maxval=self.servo_max_angle) + self.selector_bypass_angle = mmu.config.getfloat('selector_bypass_angle', -1, minval=-1, maxval=self.servo_max_angle) + self.selector_angles = list(mmu.config.getintlist('selector_gate_angles', [])) + + # Register GCODE commands specific to this module + gcode = mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_CALIBRATE_SELECTOR', self.cmd_MMU_CALIBRATE_SELECTOR, desc = self.cmd_MMU_CALIBRATE_SELECTOR_help) + gcode.register_command('MMU_SOAKTEST_SELECTOR', self.cmd_MMU_SOAKTEST_SELECTOR, desc=self.cmd_MMU_SOAKTEST_SELECTOR_help) + gcode.register_command('MMU_GRIP', self.cmd_MMU_GRIP, desc=self.cmd_MMU_GRIP_help) + gcode.register_command('MMU_RELEASE', self.cmd_MMU_RELEASE, desc=self.cmd_MMU_RELEASE_help) + + # Read all controller parameters related to selector or servo to stop klipper complaining. This + # is done to allow for uniform and shared mmu_parameters.cfg file regardless of configuration. + for option in ['selector_', 'servo_', 'cad_']: + for key in mmu.config.get_prefix_options(option): + _ = mmu.config.get(key) + + # Selector "Interface" methods --------------------------------------------- + + def reinit(self): + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def handle_connect(self): + # Load and merge calibrated selector angles (calibration set with MMU_CALIBRATE_SELECTOR) ----------- + self.selector_angles = self._ensure_list_size(self.selector_angles, self.mmu.num_gates) + + cal_selector_angles = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_ANGLES, []) + if cal_selector_angles: + self.mmu.log_debug("Loaded saved selector angles: %s" % cal_selector_angles) + else: + self.mmu.log_always("Warning: Selector angles not found in mmu_vars.cfg. Using configured defaults") + + # Merge calibrated angles with conf angles + for gate, angle in enumerate(zip(self.selector_angles, cal_selector_angles)): + if angle[1] >= 0: + self.selector_angles[gate] = angle[1] + + if not any(x == -1 for x in self.selector_angles): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + + selector_bypass_angle = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_BYPASS_ANGLE, -1) + if selector_bypass_angle >= 0: + self.selector_bypass_angle = selector_bypass_angle + self.mmu.log_debug("Loaded saved bypass angle: %s" % self.selector_bypass_angle) + + def _ensure_list_size(self, lst, size, default_value=-1): + lst = lst[:size] + lst.extend([default_value] * (size - len(lst))) + return lst + + # Actual gate selection (servo movement) can be delayed until the filament_drive/release instruction + # to prevent unecessary flutter. Conrolled by `filament_always_gripped` setting + def select_gate(self, gate): + if gate != self.mmu.gate_selected: + with self.mmu.wrap_action(self.mmu.ACTION_SELECTING): + if self.mmu.mmu_machine.filament_always_gripped: + self._grip(gate) + + def restore_gate(self, gate): + if gate == self.mmu.TOOL_GATE_BYPASS: + self.servo_state = self.mmu.FILAMENT_RELEASE_STATE + self.mmu.log_trace("Setting servo to bypass angle: %.1f" % self.selector_bypass_angle) + self._set_servo_angle(self.selector_bypass_angle) + else: + if self.mmu.mmu_machine.filament_always_gripped: + self._grip(gate) + else: + # Defer movement until filament_drive/release/hold call + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def filament_drive(self): + self._grip(self.mmu.gate_selected) + + def filament_release(self, measure=False): + if not self.mmu.mmu_machine.filament_always_gripped: + self._grip(self.mmu.gate_selected, release=True) + return 0. # Fake encoder movement + + # Common logic for servo manipulation + def _grip(self, gate, release=False): + if gate == self.mmu.TOOL_GATE_BYPASS: + self.mmu.log_trace("Setting servo to bypass angle: %.1f" % self.selector_bypass_angle) + self._set_servo_angle(self.selector_bypass_angle) + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + elif gate >= 0: + if release: + release_angle = self._get_closest_released_angle() + self.mmu.log_trace("Setting servo to filament released position at angle: %.1f" % release_angle) + self._set_servo_angle(release_angle) + self.servo_state = self.mmu.FILAMENT_RELEASE_STATE + else: + angle = self.selector_angles[gate] + self.mmu.log_trace("Setting servo to filament grip position at angle: %.1f" % angle) + self._set_servo_angle(angle) + self.servo_state = self.mmu.FILAMENT_DRIVE_STATE + else: + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def get_filament_grip_state(self): + return self.servo_state + + def buzz_motor(self, motor): + if motor == "selector": + prev_servo_angle = self.servo_angle + low = max(min(self.selector_angles), self.servo_min_angle) + high = min(max(self.selector_angles), self.servo_max_angle) + mid = (low + high) / 2 + move = (high - low) / 4 + self._set_servo_angle(angle=mid) + self._set_servo_angle(angle=mid - move) + self._set_servo_angle(angle=mid + move) + self._set_servo_angle(angle=prev_servo_angle) + else: + return False + return True + + def has_bypass(self): + return self.mmu.mmu_machine.has_bypass and self.selector_bypass_angle >= 0 + + def get_status(self, eventtime): + status = super(ServoSelector, self).get_status(eventtime) + status.update({ + 'grip': "Gripped" if self.servo_state == self.mmu.FILAMENT_DRIVE_STATE else "Released", + }) + return status + + def get_mmu_status_config(self): + msg = super(ServoSelector, self).get_mmu_status_config() + msg += ". Servo in %s position" % ("GRIP" if self.servo_state == self.mmu.FILAMENT_DRIVE_STATE else \ + "RELEASE" if self.servo_state == self.mmu.FILAMENT_RELEASE_STATE else "unknown") + return msg + + def get_uncalibrated_gates(self, check_gates): + return [gate for gate, value in enumerate(self.selector_angles) if value == -1 and gate in check_gates] + + # Internal Implementation -------------------------------------------------- + + cmd_MMU_GRIP_help = "Grip filament in current gate" + def cmd_MMU_GRIP(self, gcmd): + if self.mmu.gate_selected >= 0: + self.filament_drive() + + cmd_MMU_RELEASE_help = "Ungrip filament in current gate" + def cmd_MMU_RELEASE(self, gcmd): + if self.mmu.gate_selected >= 0: + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_release() + else: + self.mmu.log_error("Selector configured to not allow filament release") + + cmd_MMU_CALIBRATE_SELECTOR_help = "Calibration of the selector servo angle for specifed gate(s)" + def cmd_MMU_CALIBRATE_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + usage = "\nUsage: MMU_CALIBRATE_SELECTOR [GATE=x] [BYPASS=0|1] [SPACING=x] [ANGLE=x] [SAVE=0|1] [SINGLE=0|1] [SHOW=0|1]" + show = gcmd.get_int('SHOW', 0) + angle = gcmd.get_int('ANGLE', None) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + single = gcmd.get_int('SINGLE', 0, minval=0, maxval=1) + spacing = gcmd.get_float('SPACING', 25., above=0, below=180) # TiPicoMMU is 25 degrees between gates + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.mmu.mmu_machine.num_gates - 1) + if gate == -1 and gcmd.get_int('BYPASS', -1, minval=0, maxval=1) == 1: + gate = self.mmu.TOOL_GATE_BYPASS + + if show: + msg = "" + if not self.mmu.calibration_status & self.mmu.CALIBRATED_SELECTOR: + msg += "Calibration not complete\n" + msg += "Current selector gate angle positions are: %s degrees" % self.selector_angles + if self.selector_release_angle >= 0: + msg += "\nRelease angle is fixed at: %s degrees" % self.selector_release_angle + else: + msg += "\nRelease angles configured to be between each gate angle" + if self.has_bypass(): + msg += "\nBypass angle: %s" % self.selector_bypass_angle + else: + msg += "\nBypass angle not configured" + self.mmu.log_info(msg) + + elif angle is not None: + self.mmu.log_debug("Setting selector servo to angle: %d" % angle) + self._set_servo_angle(angle) + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + + elif save: + if gate == self.mmu.TOOL_GATE_BYPASS: + self.selector_bypass_angle = self.servo_angle + self.mmu.save_variable(self.VARS_MMU_SELECTOR_BYPASS_ANGLE, self.selector_bypass_angle, write=True) + self.mmu.log_info("Servo angle '%d' for bypass position has been saved" % self.servo_angle) + elif gate >= 0: + if single: + self.selector_angles[gate] = self.servo_angle + self.mmu.save_variable(self.VARS_MMU_SELECTOR_ANGLES, self.selector_angles, write=True) + self.mmu.log_info("Servo angle '%d' for gate %d has been saved" % (self.servo_angle, gate)) + else: + # If possible evenly distribute based on spacing + angles = self._generate_gate_angles(self.servo_angle, gate, spacing) + if angles: + self.selector_angles = angles + self.mmu.save_variable(self.VARS_MMU_SELECTOR_ANGLES, self.selector_angles, write=True) + self.mmu.log_info("Selector gate angle positions %s has been saved" % self.selector_angles) + else: + self.mmu.log_error("Not possible to distribute angles with separation of %.1f degrees with gate %d at %.1f%s" % (spacing, gate, self.servo_angle, usage)) + else: + self.mmu.log_error("No gate specified%s" % usage) + else: + self.mmu.log_always("Current selector servo angle: %d, Selector gate angle positions: %s" % (self.servo_angle, self.selector_angles)) + + if not any(x == -1 for x in self.selector_angles): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + + cmd_MMU_SOAKTEST_SELECTOR_help = "Soak test of selector movement" + def cmd_MMU_SOAKTEST_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_loaded(): return + if self.mmu.check_if_not_calibrated(self.mmu.CALIBRATED_SELECTOR): return + loops = gcmd.get_int('LOOP', 10) + try: + with self.mmu.wrap_sync_gear_to_extruder(): + for l in range(loops): + self.mmu.log_always("Testing loop %d / %d" % (l + 1, loops)) + tool = random.randint(0, self.mmu.num_gates - 1) + self.mmu.select_tool(tool) + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_drive() + except MmuError as ee: + self.mmu.handle_mmu_error("Soaktest abandoned because of error: %s" % str(ee)) + + def _set_servo_angle(self, angle): + if angle >= 0 and angle != self.servo_angle: + self.mmu.movequeues_wait() + self.servo.set_position(angle=angle, duration=None if self.servo_always_active else self.servo_duration) + self.servo_angle = angle + self.mmu.movequeues_dwell(max(self.servo_dwell, self.servo_duration, 0)) + + def _get_closest_released_angle(self): + if self.selector_release_angle >= 0: + return self.selector_release_angle + neutral_angles = [(self.selector_angles[i] + self.selector_angles[i + 1]) / 2 for i in range(len(self.selector_angles) - 1)] + closest_angle = 0 + min_difference = float('inf') + for angle in neutral_angles: + difference = abs(angle - self.servo_angle) + if difference < min_difference: + min_difference = difference + closest_angle = max(0, angle) + return closest_angle + + def _generate_gate_angles(self, known_angle, known_gate, spacing): + angles = [] + start_angle = known_angle - known_gate * spacing + for i in range(self.mmu.num_gates): + a = start_angle + i * spacing + if not (self.servo_min_angle <= a <= self.servo_max_angle): + return None # Not possible + angles.append(round(a)) + return angles + + + +################################################################################ +# Indexed Selector +# Implements simple Indexed Selector for type-A MMU's that uses a stepper for +# gate selection but has an indexing sensor for each gate. +# E.g. As fitted to BTT ViViD +# +# Implements commands: +# MMU_SOAKTEST_SELECTOR +################################################################################ + +class IndexedSelector(BaseSelector, object): + + def __init__(self, mmu): + super(IndexedSelector, self).__init__(mmu) + self.is_homed = True + + # Process config + self.selector_move_speed = mmu.config.getfloat('selector_move_speed', 100, minval=1.) + self.selector_homing_speed = mmu.config.getfloat('selector_homing_speed', self.selector_move_speed, minval=1.) + self.selector_touch_speed = mmu.config.getfloat('selector_touch_speed', 60, minval=1.) # Not used with ViViD but allows for param in config + self.selector_touch_enabled = mmu.config.getint('selector_touch_enabled', 1, minval=0, maxval=1) # Not used with ViViD but allows for param in config + self.selector_index_distance = mmu.config.getfloat('selector_index_distance', 5, minval=0.) + + # To simplfy config CAD related parameters are set based on vendor and version setting + self.cad_gate_width = 90. # Rotation distance set to make this equivalent to degrees + self.cad_max_rotations = 2 + + # But still allow all CAD parameters to be customized + self.cad_gate_width = mmu.config.getfloat('cad_gate_width', self.cad_gate_width, above=0.) + self.cad_max_rotations = mmu.config.getfloat('cad_max_rotations', self.cad_max_rotations, above=0.) + + # Register GCODE commands + gcode = mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_SOAKTEST_SELECTOR', self.cmd_MMU_SOAKTEST_SELECTOR, desc=self.cmd_MMU_SOAKTEST_SELECTOR_help) + + # Selector stepper setup before MMU toolhead is instantiated + section = mmu_machine.SELECTOR_STEPPER_CONFIG + if mmu.config.has_section(section): + # Inject options into selector stepper config regardless or what user sets + mmu.config.fileconfig.set(section, 'homing_speed', self.selector_homing_speed) + + # Selector "Interface" methods --------------------------------------------- + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.selector_rail = self.mmu_toolhead.get_kinematics().rails[0] + self.selector_stepper = self.selector_rail.steppers[0] + self._set_position(0) # Reset pos + + cmd_MMU_SOAKTEST_SELECTOR_help = "Soak test of selector movement" + def cmd_MMU_SOAKTEST_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_loaded(): return + if self.mmu.check_if_not_calibrated(self.mmu.CALIBRATED_SELECTOR): return + loops = gcmd.get_int('LOOP', 100) + home = bool(gcmd.get_int('HOME', 0)) + try: + with self.mmu.wrap_sync_gear_to_extruder(): + if home: + self.home() + for l in range(loops): + self.mmu.log_always("Testing loop %d / %d" % (l + 1, loops)) + tool = random.randint(0, self.mmu.num_gates) + if tool == self.mmu.num_gates: + self.mmu.select_bypass() + else: + self.mmu.select_tool(tool) + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_drive() + except MmuError as ee: + self.mmu.handle_mmu_error("Soaktest abandoned because of error: %s" % str(ee)) + + def bootup(self): + self.select_gate(self.mmu.gate_selected) + + def home(self, force_unload = None): + if self.mmu.check_if_bypass(): return + with self.mmu.wrap_action(self.mmu.ACTION_HOMING): + self.mmu.log_info("Homing MMU...") + if force_unload is not None: + self.mmu.log_debug("(asked to %s)" % ("force unload" if force_unload else "not unload")) + if force_unload is True: + # Forced unload case for recovery + self.mmu.unload_sequence(check_state=True) + elif force_unload is None and self.mmu.filament_pos != self.mmu.FILAMENT_POS_UNLOADED: + # Automatic unload case + self.mmu.unload_sequence() + self._home_selector() + + def select_gate(self, gate): + if gate >= 0: + endstop = self.selector_rail.get_extra_endstop(self._get_gate_endstop(gate)) + if not endstop: + raise MmuError("Extra endstop %s not defined on the selector stepper" % self._get_gate_endstop(gate)) + mcu_endstop = endstop[0][0] + if not mcu_endstop.query_endstop(self.mmu_toolhead.get_last_move_time()): + with self.mmu.wrap_action(self.mmu.ACTION_SELECTING): + self._find_gate(gate) + + def disable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_disable(self.mmu_toolhead.get_last_move_time()) + self.is_homed = False + + def enable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_enable(self.mmu_toolhead.get_last_move_time()) + + def buzz_motor(self, motor): + if motor == "selector": + pos = self.mmu_toolhead.get_position()[0] + self.move(None, pos + 5, wait=False) + self.move(None, pos - 5, wait=False) + self.move(None, pos, wait=False) + else: + return False + return True + + def get_mmu_status_config(self): + msg = "\nSelector is NOT HOMED" if not self.is_homed else "" + return msg + + def set_test_config(self, gcmd): + self.selector_move_speed = gcmd.get_float('SELECTOR_MOVE_SPEED', self.selector_move_speed, minval=1.) + self.selector_homing_speed = gcmd.get_float('SELECTOR_HOMING_SPEED', self.selector_homing_speed, minval=1.) + + def get_test_config(self): + msg = "\n\nSELECTOR:" + msg += "\nselector_move_speed = %.1f" % self.selector_move_speed + msg += "\nselector_homing_speed = %.1f" % self.selector_homing_speed + return msg + + # Internal Implementation -------------------------------------------------- + + def _get_max_selector_movement(self): + max_movement = self.mmu.num_gates * self.cad_gate_width * self.cad_max_rotations + return max_movement + + def _home_selector(self): + self.mmu.unselect_gate() + self.mmu.movequeues_wait() + try: + self._find_gate(0) + self.is_homed = True + except Exception as e: # Homing failed + logging.error(traceback.format_exc()) + raise MmuError("Homing selector failed because of blockage or malfunction. Klipper reports: %s" % str(e)) + + def _get_gate_endstop(self, gate): + return "unit0_gate%d" % gate + + def _find_gate(self, gate): + rotation_dir = self._best_rotation_direction(self.mmu.gate_selected, gate) + max_move = self._get_max_selector_movement() * rotation_dir + self.mmu.movequeues_wait() + actual,homed = self._trace_selector_move("Indexing selector", max_move, speed=self.selector_move_speed, homing_move=1, endstop_name=self._get_gate_endstop(gate)) + if abs(actual) > 0 and homed: + # If we actually moved to home make sure we are centered on index endstop + center_move = (self.selector_index_distance / 2) * rotation_dir + self._trace_selector_move("Centering selector", center_move, speed=self.selector_move_speed) + + # TODO automate the setup of the sequence through homing move on startup + def _best_rotation_direction(self, start_gate, end_gate): + if start_gate < 0: + return 1 # Forward direction + + sequence = [0, 2, 1, 3] # Forward order of gates + n = len(sequence) + forward_distance = reverse_distance = 0 + + # Find distance in forward direction + start_idx = sequence.index(start_gate) + for i in range(1, n): + if sequence[(start_idx + i) % n] == end_gate: + forward_distance = i + break + + # Find distance in reverse direction + rev_seq = sequence[::-1] + start_idx = rev_seq.index(start_gate) + for i in range(1, n): + if rev_seq[(start_idx + i) % n] == end_gate: + reverse_distance = i + break + + return 1 if forward_distance <= reverse_distance else -1 + + # Internal raw wrapper around all selector moves + # Returns position after move, and if homed (homing moves) + def _trace_selector_move(self, trace_str, dist, speed=None, accel=None, homing_move=0, endstop_name="default", wait=False): + null_rtn = (0., False) + homed = False + actual = dist + + self.mmu_toolhead.quiesce() + + if homing_move != 0: + # Check for valid endstop + endstops = self.selector_rail.get_endstops() if endstop_name is None else self.selector_rail.get_extra_endstop(endstop_name) + if endstops is None: + self.mmu.log_error("Endstop '%s' not found" % endstop_name) + return null_rtn + + # Set appropriate speeds and accel if not supplied + speed = speed or self.selector_homing_speed if homing_move != 0 else self.selector_move_speed + accel = accel or self.mmu_toolhead.get_selector_limits()[1] + + pos = self.mmu_toolhead.get_position() + if homing_move != 0: + try: + with self.mmu.wrap_accel(accel): + init_pos = pos[0] + pos[0] += dist + trig_pos = [0., 0., 0., 0.] + hmove = HomingMove(self.mmu.printer, endstops, self.mmu_toolhead) + trig_pos = hmove.homing_move(pos, speed, probe_pos=True, triggered=homing_move > 0, check_triggered=True) + homed = True + except self.mmu.printer.command_error as e: + homed = False + + halt_pos = self.mmu_toolhead.get_position() + actual = halt_pos[0] - init_pos + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR HOMING MOVE: max dist=%.1f, speed=%.1f, accel=%.1f, endstop_name=%s, wait=%s >> %s" % (dist, speed, accel, endstop_name, wait, "%s halt_pos=%.1f (rail moved=%.1f), trig_pos=%.1f" % ("HOMED" if homed else "DID NOT HOMED", halt_pos[0], actual, trig_pos[0]))) + + else: + with self.mmu.wrap_accel(accel): + pos[0] += dist + self.mmu_toolhead.move(pos, speed) + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR MOVE: position=%.1f, speed=%.1f, accel=%.1f" % (dist, speed, accel)) + + self.mmu_toolhead.flush_step_generation() # TTC mitigation (TODO: still required?) + self.mmu.toolhead.flush_step_generation() # TTC mitigation (TODO: still required?) + if wait: + self.mmu.movequeues_wait(toolhead=False, mmu_toolhead=True) + + if trace_str: + if homing_move != 0: + trace_str += ". Stepper: selector %s after moving %.1fmm (of max %.1fmm)" + trace_str = trace_str % (("homed" if homed else "did not home"), actual, dist) + trace_str += ". Pos: @%.1f" % self.mmu_toolhead.get_position()[0] + else: + trace_str += ". Stepper: selector moved %.1fmm" % dist + trace_str += ". Pos: @%.1f" % self.mmu_toolhead.get_position()[0] + self.mmu.log_trace(trace_str) + + return actual, homed + + def _set_position(self, position): + pos = self.mmu_toolhead.get_position() + pos[0] = position + self.mmu_toolhead.set_position(pos) + self.enable_motors() + self.is_homed = True + return position diff --git a/extras/mmu/mmu_sensor_manager.py b/extras/mmu/mmu_sensor_manager.py new file mode 100644 index 000000000000..cfa601af9050 --- /dev/null +++ b/extras/mmu/mmu_sensor_manager.py @@ -0,0 +1,322 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager to centralize mmu_sensor operations +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import random, logging, math, re + +# Happy Hare imports +from ..mmu_sensors import MmuRunoutHelper + +# MMU subcomponent clases +from .mmu_shared import MmuError + +class MmuSensorManager: + def __init__(self, mmu): + self.mmu = mmu + self.all_sensors = {} # All sensors on mmu unit optionally with unit prefix and gate suffix + self.sensors = {} # All (presence detection) sensors on active unit stripped of unit prefix + self.viewable_sensors = {} # Sensors of all types for current gate/unit renamed with simple names + + # Assemble all possible switch sensors in desired display order + sensor_names = [] + sensor_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, i) for i in range(self.mmu.num_gates)]) + sensor_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, i) for i in range(self.mmu.num_gates)]) + sensor_names.extend([ + self.mmu.SENSOR_GATE, + self.mmu.SENSOR_TENSION, + self.mmu.SENSOR_COMPRESSION, + self.mmu.SENSOR_PROPORTIONAL + ]) + if self.mmu.mmu_machine.num_units > 1: + for i in range(self.mmu.mmu_machine.num_units): + sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_GATE, i)) + sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_TENSION, i)) + sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_COMPRESSION, i)) + sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_PROPORTIONAL, i)) + sensor_names.extend([ + self.mmu.SENSOR_EXTRUDER_ENTRY, + self.mmu.SENSOR_TOOLHEAD + ]) + mmu_sensors = self.mmu.printer.lookup_object("mmu_sensors") + self.all_sensors = mmu_sensors.sensors + + # Special case for "no bowden" (one unit) designs where mmu_gate is an alias for extruder sensor + if not self.mmu.mmu_machine.require_bowden_move and self.all_sensors.get(self.mmu.SENSOR_EXTRUDER_ENTRY, None) and self.mmu.SENSOR_GATE not in self.all_sensors: + self.all_sensors[self.mmu.SENSOR_GATE] = self.all_sensors[self.mmu.SENSOR_EXTRUDER_ENTRY] + + # Setup subset of filament sensors that are also used for homing (endstops) + self.endstop_names = [] + self.endstop_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, i) for i in range(self.mmu.num_gates)]) + self.endstop_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, i) for i in range(self.mmu.num_gates)]) + self.endstop_names.extend([ + self.mmu.SENSOR_GATE, + self.mmu.SENSOR_TENSION, + self.mmu.SENSOR_COMPRESSION + ]) + if self.mmu.mmu_machine.num_units > 1: + for i in range(self.mmu.mmu_machine.num_units): + self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_GATE, i)) + self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_COMPRESSION, i)) + self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_TENSION, i)) + self.endstop_names.extend([ + self.mmu.SENSOR_EXTRUDER_ENTRY, + self.mmu.SENSOR_TOOLHEAD + ]) + # TODO Assumes one stepper but in theory could be on all + self.endstop_names.extend([ + self.mmu.SENSOR_GEAR_TOUCH + ]) + for name in self.endstop_names: + sensor = self.all_sensors.get(name, None) + if sensor is not None: + if sensor.__class__.__name__ in ["MmuAdcSwitchSensor", "MmuHallEndstop"]: + sensor_pin = sensor.runout_helper.switch_pin + mcu_endstop = self.mmu.gear_rail.add_extra_endstop(sensor_pin, name, mcu_endstop=sensor) + else: + # Add sensor pin as an extra endstop for gear rail + sensor_pin = sensor.runout_helper.switch_pin + ppins = self.mmu.printer.lookup_object('pins') + pin_params = ppins.parse_pin(sensor_pin, True, True) + share_name = "%s:%s" % (pin_params['chip_name'], pin_params['pin']) + ppins.allow_multi_use_pin(share_name) + mcu_endstop = self.mmu.gear_rail.add_extra_endstop(sensor_pin, name) + + # This ensures rapid stopping of extruder stepper when endstop is hit on synced homing + # otherwise the extruder can continue to move a small (speed dependent) distance + if self.mmu.homing_extruder and name in [self.mmu.SENSOR_TOOLHEAD, self.mmu.SENSOR_COMPRESSION, self.mmu.SENSOR_TENSION]: + mcu_endstop.add_stepper(self.mmu.mmu_extruder_stepper.stepper) + else: + logging.warning("MMU: Filament sensor %s is not defined in [mmu_sensors]" % name) + + # Reset the "viewable" sensors used in UI (unit must be updated first) + def reset_active_gate(self, gate): + sensor_name_map = { + self.mmu.SENSOR_PRE_GATE_PREFIX: self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, gate), + self.mmu.SENSOR_GEAR_PREFIX: self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, gate), + self.mmu.SENSOR_GATE: self.get_mapped_endstop_name(self.mmu.SENSOR_GATE), + self.mmu.SENSOR_COMPRESSION: self.get_mapped_endstop_name(self.mmu.SENSOR_COMPRESSION), + self.mmu.SENSOR_TENSION: self.get_mapped_endstop_name(self.mmu.SENSOR_TENSION), + self.mmu.SENSOR_PROPORTIONAL: self.get_mapped_endstop_name(self.mmu.SENSOR_PROPORTIONAL), + self.mmu.SENSOR_EXTRUDER_ENTRY: self.mmu.SENSOR_EXTRUDER_ENTRY, + self.mmu.SENSOR_TOOLHEAD: self.mmu.SENSOR_TOOLHEAD + } + self.viewable_sensors = { + name: self.all_sensors.get(mapped_name) + for name, mapped_name in sensor_name_map.items() + if self.all_sensors.get(mapped_name) is not None + } + + # Activate only sensors for current unit and rename for access + def reset_active_unit(self, unit): + self.sensors = {} + for name, sensor in self.all_sensors.items(): + if name.startswith("unit_"): + if unit is not None and name.startswith("unit_" + str(unit)): + self.sensors[re.sub(r'unit_\d+_', '', name)] = sensor + sensor.runout_helper.enable_button_feedback(True) + else: + # Ensure any excluded sensor is completely deactivated + sensor.runout_helper.enable_runout(False) + sensor.runout_helper.enable_button_feedback(False) + else: + self.sensors[name] = sensor + + # Return dict of all sensor states (or None if sensor disabled) + def get_all_sensors(self, inactive=False): + names = {} + for name, sensor in self.sensors.items() if not inactive else self.all_sensors.items(): + names[name] = bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None + return names + + def has_sensor(self, name): + return self.sensors[name].runout_helper.sensor_enabled if name in self.sensors else False + + def has_gate_sensor(self, name, gate): + return self.sensors[self.get_gate_sensor_name(name, gate)].runout_helper.sensor_enabled if self.get_gate_sensor_name(name, gate) in self.sensors else False + + def get_gate_sensor_name(self, name, gate): + return "%s_%d" % (name, gate) # Must match mmu_sensors + + def get_unit_sensor_name(self, name, unit): + return "unit_%d_%s" % (unit, name) # Must match mmu_sensors + + def get_unitless_sensor_name(self, name): + return re.sub(r'unit_\d+_', '', name) + + # Get unit or gate specific endstop if it exists + # Take generic name and look for "_genericName" and "genericName_" + def get_mapped_endstop_name(self, endstop_name): + mapped_name = self.get_unit_sensor_name(endstop_name, self.mmu.unit_selected) + if mapped_name in self.endstop_names: + return mapped_name + + mapped_name = self.get_gate_sensor_name(endstop_name, self.mmu.gate_selected) + if mapped_name in self.endstop_names: + return mapped_name + + return endstop_name + + # Return sensor state or None if not installed + def check_sensor(self, name): + sensor = self.sensors.get(name, None) + if sensor is not None and sensor.runout_helper.sensor_enabled: + detected = bool(sensor.runout_helper.filament_present) + return detected + else: + return None + + # Return per-gate sensor state or None if not installed + def check_gate_sensor(self, name, gate): + sensor_name = self.get_gate_sensor_name(name, gate) + sensor = self.sensors.get(sensor_name, None) + if sensor is not None and sensor.runout_helper.sensor_enabled: + detected = bool(sensor.runout_helper.filament_present) + return detected + else: + return None + + # Returns True if ALL sensors before position detect filament + # None if NO sensors available (disambiguate from non-triggered sensor) + # Can be used as a "filament continuity test" + def check_all_sensors_before(self, pos, gate, loading=True): + sensors = self.get_sensors_before(pos, gate, loading) + if all(state is None for state in sensors.values()): + return None + return all(state is not False for state in sensors.values()) + + # Returns True if ANY sensor before position detects filament + # None if NO sensors available (disambiguate from non-triggered sensor) + # Can be used as a filament visibility test over a portion of the travel + def check_any_sensors_before(self, pos, gate, loading=True): + sensors = self.get_sensors_before(pos, gate, loading) + if all(state is None for state in sensors.values()): + return None + return any(state is True for state in sensors.values()) + + # Returns True if ALL sensors after position detect filament + # None if NO sensors available (disambiguate from non-triggered sensor) + # Can be used as a "filament continuity test" + def check_all_sensors_after(self, pos, gate, loading=True): + sensors = self.get_sensors_after(pos, gate, loading) + if all(state is None for state in sensors.values()): + return None + return all(state is not False for state in sensors.values()) + + # Returns True if ANY sensor after position detects filament + # None if no sensors available (disambiguate from non-triggered sensor) + # Can be used to validate position + def check_any_sensors_after(self, pos, gate, loading=True): + sensors = self.get_sensors_after(pos, gate, loading) + if all(state is None for state in sensors.values()): + return None + return any(state is True for state in sensors.values()) + + # Returns True if all sensors in current filament path are triggered + # None if no sensors available (disambiguate from non-triggered sensor) + def check_all_sensors_in_path(self): + sensors = self.get_sensors_before(self.mmu.FILAMENT_POS_LOADED, self.mmu.gate_selected) + if all(state is None for state in sensors.values()): + return None + return all(state is not False for state in sensors.values()) + + # Returns True if any sensors in current filament path are triggered (EXCLUDES pre-gate) + # None if no sensors available (disambiguate from non-triggered sensor) + def check_any_sensors_in_path(self): + sensors = self.get_all_sensors_for_gate(self.mmu.gate_selected) + if all(state is None for state in sensors.values()): + return None + return any(state is True for state in sensors.values()) + + # Returns True is any sensors in filament path are not triggered + # None if no sensors available (disambiguate from non-triggered sensor) + # Can be used to spot failure in "continuity" i.e. runout + def check_for_runout(self): + sensors = self.get_sensors_before(self.mmu.FILAMENT_POS_LOADED, self.mmu.gate_selected) + if all(state is None for state in sensors.values()): + return None + return any(state is False for state in sensors.values()) + + # Error with explanation if any filament sensors don't detect filament + def confirm_loaded(self): + sensors = self.get_sensors_before(self.mmu.FILAMENT_POS_LOADED, self.mmu.gate_selected) + if any(state is False for state in sensors.values()): + MmuError("Loaded check failed:\nFilament not detected by sensors: %s" % ', '.join([name for name, state in sensors.items() if state is False])) + + # Return formatted summary of all sensors under management (include all mmu units) + def get_sensor_summary(self, detail=False): + summary = "" + for name, state in self.get_all_sensors(inactive=True).items(): + if state is not None or detail: + sensor = self.all_sensors.get(name) + if name in [self.mmu.SENSOR_PROPORTIONAL]: + # Special case analog sensor + value = sensor.get_status(0).get('value', 0.) + value_raw = sensor.get_status(0).get('value_raw', 0.) + summary += "%s: %.2f" % (name, ("(%.2f, currently disabled)" % value) if state is None else value) + if detail: + summary += " (raw: %.2f)" % (value_raw) + else: + trig = "%s" % 'TRIGGERED' if sensor.runout_helper.filament_present else 'Open' + summary += "%s: %s" % (name, ("(%s, currently disabled)" % trig) if state is None else trig) + if detail and sensor.runout_helper.runout_suspended is not None and state is not None: + summary += "%s" % (", Runout enabled" if not sensor.runout_helper.runout_suspended else "") + summary += "\n" + return summary + + def enable_runout(self, gate): + self._set_sensor_runout(True, gate) + + def disable_runout(self, gate): + self._set_sensor_runout(False, gate) + + def _set_sensor_runout(self, enable, gate): + for name, sensor in self.sensors.items(): + if isinstance(sensor.runout_helper, MmuRunoutHelper): + per_gate = re.search(r'_(\d+)$', name) # Must match mmu_sensors + if per_gate: + sensor.runout_helper.enable_runout(enable and (int(per_gate.group(1)) == gate)) + else: + sensor.runout_helper.enable_runout(enable and (gate != self.mmu.TOOL_GATE_UNKNOWN)) + + # Defines sensors and relationship to filament_pos state for easy filament tracing + def _get_sensors(self, pos, gate, position_condition): + result = {} + if gate >= 0: + # Note: For gear sensor the position of POS_HOMED_GATE is only valid if is not usually triggered (i.e. parking retract) + sensor_selection = [ + (self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, gate), None), + (self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, gate), self.mmu.FILAMENT_POS_HOMED_GATE if self.mmu.gate_homing_endstop == self.mmu.SENSOR_GEAR_PREFIX and self.mmu.gate_parking_distance <= 0 else None), + (self.mmu.SENSOR_GATE, self.mmu.FILAMENT_POS_HOMED_GATE), + (self.mmu.SENSOR_EXTRUDER_ENTRY, self.mmu.FILAMENT_POS_HOMED_ENTRY), + (self.mmu.SENSOR_TOOLHEAD, self.mmu.FILAMENT_POS_HOMED_TS), + ] + for name, position_check in sensor_selection: + sensor = self.sensors.get(name, None) + if sensor and position_condition(pos, position_check): + result[name] = bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None + return result # TODO handle bypass and return only EXTRUDER_ENTRY and TOOLHEAD sensors + + def get_sensors_before(self, pos, gate, loading=True): + return self._get_sensors(pos, gate, lambda p, pc: pc is None or (loading and p >= pc) or (not loading and p > pc)) + + def get_sensors_after(self, pos, gate, loading=True): + return self._get_sensors(pos, gate, lambda p, pc: pc is not None and ((loading and p < pc) or (not loading and p <= pc))) + + def get_all_sensors_for_gate(self, gate): + return self._get_sensors(-1, gate, lambda p, pc: pc is not None) + + def get_status(self, eventtime=None): + result = { + name: bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None + for name, sensor in self.viewable_sensors.items() + } + return result diff --git a/extras/mmu/mmu_shared.py b/extras/mmu/mmu_shared.py new file mode 100644 index 000000000000..af310185543b --- /dev/null +++ b/extras/mmu/mmu_shared.py @@ -0,0 +1,38 @@ +# Happy Hare MMU Software +# Shared classes +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import sys + +# Default to not using unicode on Python2. Not worth the hassle until klipper drops py2 support! +UI_SPACE, UI_SEPARATOR, UI_DASH, UI_DEGREE, UI_BLOCK, UI_CASCADE = ' ', '.', '-', '^', '*', '-' +UI_BOX_TL, UI_BOX_BL, UI_BOX_TR, UI_BOX_BR = '+', '+', '+', '+' +UI_BOX_L, UI_BOX_R, UI_BOX_T, UI_BOX_B = '+', '+', '+', '+' +UI_BOX_M, UI_BOX_H, UI_BOX_V = '+', '-', '|' +UI_EMOTICONS = ['?', 'A+', 'A', 'B', 'C', 'C-', 'D', 'F'] +UI_SQUARE, UI_CUBE = '^2', '^3' + + +if sys.version_info[0] >= 3: + # Use (common) unicode for improved formatting and klipper layout + UI_SPACE, UI_SEPARATOR, UI_DASH, UI_DEGREE, UI_BLOCK, UI_CASCADE = '\u00A0', '\u00A0', '\u2014', '\u00B0', '\u2588', '\u2514' + # Not all character sets include these so best to use defaults above + # UI_BOX_TL, UI_BOX_BL, UI_BOX_TR, UI_BOX_BR = '\u250C', '\u2514', '\u2510', '\u2518' + # UI_BOX_L, UI_BOX_R, UI_BOX_T, UI_BOX_B = '\u251C', '\u2524', '\u252C', '\u2534' + # UI_BOX_M, UI_BOX_H, UI_BOX_V = '\u253C', '\u2500', '\u2502' + UI_EMOTICONS = [UI_DASH, '\U0001F60E', '\U0001F603', '\U0001F60A', '\U0001F610', '\U0001F61F', '\U0001F622', '\U0001F631'] + UI_SQUARE, UI_CUBE = '\u00B2', '\u00B3' + + +# Mmu exception error class +class MmuError(Exception): + pass diff --git a/extras/mmu/mmu_sync_controller.py b/extras/mmu/mmu_sync_controller.py new file mode 100644 index 000000000000..6fcfba9b9928 --- /dev/null +++ b/extras/mmu/mmu_sync_controller.py @@ -0,0 +1,1692 @@ +# -*- coding: utf-8 -*- +# +# Happy Hare MMU Software +# Sync Feedback Controller +# +# This helper module implements a motion-triggered filament tension controller — that adapts gear +# stepper rotation distance (RD) dynamically based on sensor feedback. It offers modes of operation: +# +# 1) Simple dual level RD selection that works with CO (Compression only switch), +# TO (Tension only switch), and optionally with D (Dual switch) or P (Proportional) sensors. +# +# 2) Combined proportional-derivative (PD) controller with Extended Kalman Filter +# (EKF) for optimal results with P (Proportional) sensor. +# +# Flowguard: It also implements protection for all modes/sensor types that will trigger +# on clog (at extruder) or tangle (at MMU) conditions. +# +# Autotune: An autotuning option can be enabled for dynamic tuning (and persistence) of +# calibrated MMU gear rotation_distance. +# +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# + +import math +import io, json # for debug log + +from collections import deque + +# --- dataclass shim (Py2.7-safe). If real dataclasses are available (Py3.7+), we use them. --- +try: + from dataclasses import dataclass # noqa: F401 +except Exception: + def dataclass(_cls=None, **_kwargs): + """ + Minimal no-op @dataclass shim for Py2.7: + - Leaves attributes as-is (default values taken from class dict). + - If class has no __init__, provide a simple kwargs-based initializer that sets attributes. + """ + def wrap(cls): + if not hasattr(cls, "__init__"): + def __init__(self, **kw): + for k, v in kw.items(): + setattr(self, k, v) + cls.__init__ = __init__ + return cls + return wrap if _cls is None else wrap(_cls) + +# --- math.isclose replacement for Py2.7 --- +def _isclose(a, b, rel_tol=1e-09, abs_tol=0.0): + return abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol) + + +# Sync-Feedback sensor type: +# SensorType = Literal["P", "D", "CO", "TO"] +# (drop typing constructs for Py2.7 compatibility) + +# ----------------------------------------------------------------------------- +# SyncControllerConfig reference +# ----------------------------------------------------------------------------- +# +# Mechanics +# - buffer_range_mm (mm) Usable sensor travel that maps linearly to x ∈ [-1,+1]. +# All control logic is normalized by this. Increase if your +# sensor saturates too easily; decrease for a “tighter” x scale. +# - buffer_max_range_mm (mm) Physical clamp of the spring/buffer travel (|x| clipping). +# Must be ≥ buffer_range_mm. Used by the simulator and for +# visualization/safety margins. +# - sensor_type "P" => proportional z ∈ [-1, +1]; uses EKF + PD + KD +# "D" => discrete dual-switch z ∈ {-1,0,+1}; twolevel only +# "CO" => compression-only switch z ∈ {0,+1}; twolevel only +# "TO" => tension_only switch z ∈ {-1,0}; twolevel only +# +# Core lag tuning (readiness r) +# - sensor_lag_mm (mm) Motion required before treating sensor changes as “fresh info”. +# r ramps from 0→1 across this distance (gates smoothing/rates). +# 0 disables gating (r=1 always). +# - info_delta_a For Type-P only: minimum |Δz| to count as “new info”. +# Helps suppress tiny noise from constantly resetting the lag meter. +# +# Gains (PD on x with deadband) +# - kp Proportional gain on x (after deadband). Larger => stronger pull +# toward neutral; too high can oscillate near zero. +# - kd Derivative on x (Type-P only; requires dt>0). Dampens fast x +# changes. Set 0 to disable if your signal is noisy. +# - ctrl_deadband No-action band around x=0. Prevents over-correcting tiny errors. +# +# EKF noises +# - q_x Process noise on x. Larger trusts the model less => faster tracking, +# but noisier estimates. +# - q_c Process noise on c (calibration). Larger lets c drift/learn faster. +# - r_type Measurement noise for Type-P. Larger trusts the sensor less. +# +# Calibration bounds +# - c_min, c_max Hard clamps for c (effective compliance/throughput factor). +# Keep wide enough to cover materials but not so wide that c runs away. +# +# FlowGuard (distance-based) +# - flowguard_extreme_threshold Threshold in x or z treated as “pegged” (≈ jam/tangle). +# Used for detection, readiness floor, and relief logic. +# - flowguard_relief_mm (mm) Required accumulated “relief” motion to prove we tried to +# correct an extreme. If None, defaults to buffer_max_range_mm. USER EXPOSED +# +# Rotation distance +# - rd_start (mm) Default/persisted baseline RD. Used as mirror reference for mapping +# - rd_min_max_speed_multiplier Allowed RD bounds based on % speed +# - rd_twolevel_speed_multiplier Min/Max RD based on % speed for twolevel operation USER EXPOSED +# - rd_twolevel_boost_multiplier Extra boost speed USER EXPOSED +# +# Distance-based smoothing & slew +# - rd_filter_len_mm (mm) Exponential smoothing length vs extruder motion. +# Alpha = 1 - exp(-|Δmm| / L). Larger L = slower RD changes. +# - rd_rate_per_mm Hard rate limit on |ΔRD| per mm of motion (scaled by readiness r). +# None disables. Works together with rd_filter_len_mm; the tighter one wins. +# +# Extreme behavior +# - readiness_extreme_floor Minimum readiness r when the sensor/estimate is pegged. Ensures +# RD can change quickly enough under clear faults. +# - rate_extreme_multiplier Multiplier on rd_rate_per_mm when pegged (speed up corrections). +# - snap_at_extremes If True, apply relief-biased snap when pegged +# per update) to move away from the peg. +# - extreme_relief_frac Fraction of |d_ext| used to compute a relief RD step each update +# when snap_at_extremes is active. Typical 0.15–0.35. +# +# Autotune +# EKF logic: +# - autotune_stable_x_thresh Consider “near neutral” if |x| ≤ this. +# Determines when we accumulate samples for autotune. +# - autotune_stable_time_s Minimum time spent near neutral before we consider autotuning. +# - autotune_basis "time" | "motion" | "either" | "both" — which tests must pass. +# - autotune_motion_mm Motion near neutral required if basis uses motion. +# considered too small to avoid recommending trivial changes. +# - autotune_var_rel_frac Max allowed std(speed) near neutral required for autotune to propose an update +# - autotune_var_len_mm Distance over which to estimate RD mean/variance during the near-neutral “stable” window. +# Twolevel logic: +# - autotune_significance_z Z-score tests for twolevel estimator (0 disables, 2≈95% confidence). +# Shared logic: +# - autotune_cooldown_s/mm Minimum time/motion since the last autotune before another suggestion. +# +# Tuning tips: +# - If RD reacts too sluggishly in normal operation, decrease rd_filter_len_mm and/or increase +# rd_rate_per_mm (watch stability near neutral). +# - If you see chatter near x=0, reduce kp and/or kd, or increase r_type. +# - If FlowGuard trips too early, raise flowguard_relief_mm. +# - If autotune fires too often, increase the cooldowns; if it +# never fires, reduce autotune_stable_time_s and/or autotune_motion_mm. + +# -------------------------- Config dataclass (annotation-free) ------------------------- +@dataclass +class SyncControllerConfig(object): + # Logging + log_sync = False # whether to create log of every tick for debugging purposes + log_file = "/tmp/sync.jsonl" # debugging/plotting json log + + # Mechanics + buffer_range_mm = 8.0 # sensor usable travel (maps to normalized [-1,+1]) + buffer_max_range_mm = 14.0 # physical max travel (spring clamp) ≥ buffer_range_mm + sensor_type = "D" # "P" | "D" | "CO" | "TO" + + # Core lag tuning (readiness r) + sensor_lag_mm = 0.0 # expected motion to see new info; 0 => no lag gating (r=1) + info_delta_a = 0.08 # Type-P: min sensor delta to count as "new info" + + # Gains (PD on x with deadband) + kp = 0.5 + kd = 0.4 # derivative term (used for Type-P) + ctrl_deadband = 0.1 # neutral deadband for PD around x=0 + + # EKF noises + q_x = 1e-3 + q_c = 5e-5 + r_type = 2.5e-2 + + # Calibration bounds + c_min = 0.25 + c_max = 4.0 + + # FlowGuard (distance-based) + flowguard_extreme_threshold = 0.9 + flowguard_relief_mm = None + + # Rotation distance + rd_start = 20.0 # initial baseline (previous calibrated value) + rd_min_max_speed_multiplier = 0.25 # ±25% speed + rd_twolevel_speed_multiplier = 0.05 # ±5% speed + rd_twolevel_boost_multiplier = 0.05 # ±5% extra boost speed + + # Distance-based smoothing & slew + rd_filter_len_mm = 25.0 # exp smoothing length (mm of extruder motion for ~63% step @ r=1) + rd_rate_per_mm = 0.10 # per-mm hard rate limit on ΔRD (scaled by readiness) + + # Extreme behavior control + readiness_extreme_floor = 0.7 # when pegged, raise r to at least this + rate_extreme_multiplier = 2.0 # multiply rate cap when pegged + snap_at_extremes = True # enable relief-biased snap when pegged + extreme_relief_frac = 0.25 # fraction of |d_ext| of guaranteed relief per update + + # EKF autotune logic tests + autotune_stable_x_thresh = 0.12 + autotune_stable_time_s = 4.0 + autotune_basis = "both" + autotune_motion_mm = None + autotune_var_rel_frac = 0.004 # allow ≈0.4% relative speed std + autotune_var_len_mm = None + + # Twolevel logic tests + autotune_significance_z = 1.0 # z-score (twolevel confidence) threshold to accept new RD (0 disables, 1≈68%, 2≈96%) + + # Shared tests + autotune_cooldown_s = 10.0 + autotune_cooldown_mm = 100.0 + autotune_min_save_frac = 0.001 # Only consider saving if > ≈0.1% speed change from last persisted value + + # Certainty tracking of rd recommendations + autotune_cert_window = 8 # fifo length of rd certainty scores + autotune_cert_tau_rel = 0.01 # target relative SE (e.g. 1%) + autotune_cert_n0 = 3.0 # prior sample penalty + autotune_cert_hysteresis = 0.001 # min score improvement to accept + + os_min_flip_mm = 0.0 # minimum motion between flips (anti-chatter) + + # Optional two-level for P type sensors + use_twolevel_for_type_p = None # True/False to force option for type-P sensors + p_twolevel_threshold = 0.80 # P extreme if z>=+thr or z<=-thr + p_twolevel_hysteresis = 0.2 # shrink threshold by this when exiting a twolevel extreme + + # dataclass shim doesn’t call __post_init__ automatically on Py2; do the work in __init__ + def __init__(self, **kw): + # apply defaults from class dict + for k, v in self.__class__.__dict__.items(): + if not k.startswith("_") and not callable(v): + setattr(self, k, v) + # apply user overrides + for k, v in kw.items(): + setattr(self, k, v) + + # --- begin original __post_init__ logic --- + if self.buffer_range_mm <= 0: + raise ValueError("buffer_range_mm must be > 0") + if self.buffer_max_range_mm <= 0: + raise ValueError("buffer_max_range_mm must be > 0") + if self.buffer_max_range_mm < self.buffer_range_mm: + raise ValueError("buffer_max_range_mm must be ≥ buffer_range_mm") + + # Autotune window defaults + if self.autotune_motion_mm is None: + self.autotune_motion_mm = 3.0 * self.rd_filter_len_mm + if self.autotune_var_len_mm is None: + self.autotune_var_len_mm = 1.8 * self.rd_filter_len_mm + + # FlowGuard relief threshold (how much "counter-effort" must be proven) + if self.flowguard_relief_mm is None: + mult = 0.3 if self.sensor_type in ['P'] else 0.7 + self.flowguard_relief_mm = max(mult * self.buffer_range_mm, self.buffer_max_range_mm) + + +# ------------------------------- EKF State ------------------------------ +@dataclass +class EKFState(object): + """ + EKF state for [x, c] with covariance. + """ + x = 0.0 + c = 1.0 + P11 = 0.5 + P12 = 0.0 + P22 = 0.2 + x_prev = 0.0 + + def __init__(self): + # Attributes already have defaults + pass + + +# ------------------------------ Autotune Engine ------------------------- +class _AutotuneEngine(object): + """ + Helper object that owns *all* autotune bookkeeping and decisions + """ + + def __init__(self, ctrl): + self.ctrl = ctrl + + # Core counters and state + self._total_motion_mm = 0.0 + self._total_time_s = 0.0 + self._paused = False + + # PD window stats + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + self._rd_ema_mean = None + self._rd_ema_var = 0.0 + + # Autotune anchors & cooldown trackers + self._autotune_last_time_s = -1e12 + self._autotune_last_motion_mm = -1e12 + self._autotune_baseline = self.ctrl.rd_ref # Persisted rd setting + self._autotune_current = self.ctrl.rd_ref # Current recommendation + self._autotune_min_cert_score = 0.5 # Don't recommend persist if less than this score + + # Suggestion tracking + self._rd_cert_fifo = deque(maxlen=int(max(1, ctrl.cfg.autotune_cert_window))) + self._rd_cert_last_score = -1.0 + + # Two-level estimator buckets & evidence + self._tl_flips = 0 + self._tl_updates_since_flip = 0 + + # Segment/cycle tracking for two-level duty estimator + self._tl_seg_level = None # "low" / "high" + self._tl_seg_mm = 0.0 # +ve (prevailing extrude) or -ve (prevailing retract) + self._tl_seg_mm_extreme = 0.0 # +ve (prevailing extrude) or -ve (prevailing retract) + self._tl_samples_low = [] # FIFO of extruder distances traveled while in "low" state + self._tl_samples_high = [] # FIFO of extruder distances traveled while in "high" state + self._tl_last_unpaired_low = None + self._tl_last_unpaired_high = None + self._tl_cycles = [] # List of (low_mm, high_mm) + self._tl_seg_window = 6 # Moving window per level + self._tl_cycle_window = 4 # Moving window of paired cycles + if ctrl.cfg.sensor_type in ['CO', 'TO']: + self._tl_min_cycles = 4 # Required minimum number of samples + else: + self._tl_min_cycles = 2 # Less because using full "buffer_range" + + # -------------------------------- API ----------------------------------- + + def restart(self, rd_init, reset_totals=True, reset_cooldown=True, reset_confidence=True): + """ + Rebase all autotune anchors/windows on a fresh baseline for new starting rd value + - Cooldown timers are either reset-to-now (default) or to a large negative origin + - Total counts are optionally reset + """ + self._autotune_current = rd_init + self._paused = False + + # Reset or preserve cooldown origins + if reset_cooldown: + self._autotune_last_time_s = self._total_time_s + self._autotune_last_motion_mm = self._total_motion_mm + else: + self._autotune_last_time_s = -1e12 + self._autotune_last_motion_mm = -1e12 + + # Suggestion tracking + if reset_confidence: + self._rd_cert_fifo = deque(maxlen=int(max(1, self.ctrl.cfg.autotune_cert_window))) + self._rd_cert_last_score = -1.0 + + # Reset or preserve core counters + if reset_totals: + self._total_motion_mm = 0.0 + self._total_time_s = 0.0 + + # PD window stats + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + + # Two-level estimator buckets & evidence + self._tl_flips = 0 + self._tl_updates_since_flip = 0 + + # Clear segment/cycle tracking for two-level duty estimator + self._tl_seg_level = None + self._tl_seg_mm = 0.0 + self._tl_seg_mm_extreme = 0.0 + self._tl_samples_low = [] + self._tl_samples_high = [] + self._tl_last_unpaired_low = None + self._tl_last_unpaired_high = None + self._tl_cycles = [] + + + def pause(self): + """ + Called to pause autotune generally because we received a large retract or we know + we are going to do an extended retract (tuning only work reliably in a extruder direction) + or we are performing movement that is known to cause underextrusion (like blobifer purge). + """ + if not self._paused: + self._paused = True + + + def resume(self): + """ + Resume autotune monitoring. We have to perform a soft reset. + """ + if self._paused: + self.restart(self._autotune_current, reset_totals=False, reset_cooldown=False, reset_confidence=False) + + + def note_twolevel_tick(self, os_level, flipped, d_ext, is_extreme=False): + """ + Called once per update_autotune() in two-level branch to keep buckets/evidence up-to-date. + """ + if self._paused: return + + cfg = self.ctrl.cfg + + # Flip handling + if flipped: + self._tl_updates_since_flip = 0 + self._tl_flips += 1 + else: + self._tl_updates_since_flip += 1 + + # Only accumulate segments after the first flip to remove startup conditions + if self._tl_flips < 1: + return + + # Accumulate current segment distance + self._tl_seg_mm += d_ext + if is_extreme: + self._tl_seg_mm_extreme += d_ext + + # On flip: close previous segment (if started) and store sample + if flipped: + seg_level = self._tl_seg_level + seg_mm = abs(self._tl_seg_mm) + + if seg_level == "low": + self._tl_samples_low.append(seg_mm) + if len(self._tl_samples_low) > self._tl_seg_window: + self._tl_samples_low.pop(0) + # Pair with existing high if available + if self._tl_last_unpaired_high is not None: + self._tl_cycles.append((seg_mm, self._tl_last_unpaired_high)) + if len(self._tl_cycles) > self._tl_cycle_window: + self._tl_cycles.pop(0) + self._tl_last_unpaired_high = None + else: + self._tl_last_unpaired_low = seg_mm + + elif seg_level == "high": + self._tl_samples_high.append(seg_mm) + if len(self._tl_samples_high) > self._tl_seg_window: + self._tl_samples_high.pop(0) + # Pair with existing low if available + if self._tl_last_unpaired_low is not None: + self._tl_cycles.append((self._tl_last_unpaired_low, seg_mm)) + if len(self._tl_cycles) > self._tl_cycle_window: + self._tl_cycles.pop(0) + self._tl_last_unpaired_low = None + else: + self._tl_last_unpaired_high = seg_mm + + # Start new segment for the new level + self._tl_seg_level = os_level + self._tl_seg_mm = 0.0 + self._tl_seg_mm_extreme = 0.0 + + + def update_autotune(self, d_ext, dt_s, report_trivial=False): + """ + On sensor update, recommend rd update based on mode: + - If two-level mode is active (CO/TO/D, or P with use_twolevel_for_type_p=True), + only query the two-level estimator. + - Otherwise, only query the PD near-neutral window. + If rd is recommended, run through shared statistical tests + """ + status = {"rd": None, "note": "", "save": False} + + if self._paused: + status["note"] = "Autotune: Paused" + return status + + cfg = self.ctrl.cfg + + # Track time/movement + self._total_time_s += max(0.0, float(dt_s)) + self._total_motion_mm += abs(float(d_ext)) + travel = "@{:.0f}s/{:.0f}mm".format(self._total_time_s, self._total_motion_mm) + + # Cooldown - sufficient motion/time since last save + since_mm = self._total_motion_mm - self._autotune_last_motion_mm + since_s = self._total_time_s - self._autotune_last_time_s + req_mm = cfg.autotune_cooldown_mm + req_s = cfg.autotune_cooldown_s + if since_mm < req_mm or since_s < req_s: + return status + + if self.ctrl.twolevel_active: + rec_rd, note = self._recommend_rd_from_twolevel() + else: + rec_rd, note = self._recommend_rd_from_ekf_path(d_ext, dt_s) + + # No recommendation but optional reject note + if rec_rd is None: + status["note"] = "Autotune: {} {}".format(travel, note) if note else "" + return status + + # Perform final shared checks on recommendation... + + if not (self.ctrl.rd_low <= rec_rd <= self.ctrl.rd_high): + status["note"] = "Autotune: {} Rejected rd {:.4f} because out of bounds!".format(travel, rec_rd) + return status + + # This makes is progressively harder to accept autotune + rec_rd, _note = self._autotune_confident(rec_rd) + if rec_rd is None: + status["note"] = "Autotune: {} {}".format(travel, _note) if _note else "" + return status + + # Do nothing on truly trivial changes + if not report_trivial and _isclose(rec_rd, self._autotune_current, abs_tol=1e-3): + status["note"] = "Autotune: {} Rejected rd {:.4f} because too trivial a delta".format(travel, rec_rd) + return status + + # We have new tuned rd value... + self._autotune_current = rec_rd + status["rd"] = rec_rd + status["note"] = "Autotune: {} {} and {}".format(travel, note, _note) + + # Should we recommend saving as new default reference? + if self._rd_cert_last_score >= self._autotune_min_cert_score: + frac = self._frac_speed_delta(rec_rd, self._autotune_baseline) + min_frac = cfg.autotune_min_save_frac + if frac >= min_frac: + self._autotune_baseline = rec_rd + status["save"] = True + + self.restart(rec_rd, reset_totals=False, reset_cooldown=False, reset_confidence=False) + return status + + + def twolevel_phase(self, exclude_extreme=False): + """ + Return (level, phase) for current two-level segment where + phase is measured between extremes (extreme motion removed). + Return None if we don't have enough evidence yet. + phase : progress in [0,1] within current segment (distance-based) + level : "low" | "high" (segment we're currently in) + extruding : true if extruding + """ + level = self._tl_seg_level + if level is None: + return None + + samples = self._tl_samples_high if level == "high" else self._tl_samples_low + if not samples: + return None + + mean_len = sum(samples) / float(len(samples)) + travel = abs(self._tl_seg_mm) + if exclude_extreme: + travel -= abs(self._tl_seg_mm_extreme) + mean_len -= abs(self._tl_seg_mm_extreme) + phase = max(0.0, min(1.0, travel / max(1e-6, mean_len))) + + return phase, level, self._tl_seg_mm > 0 + + + def get_rec_rd(self): + """ + Return the current recommended RD + """ + return self._autotune_current + + def get_tuned_rd(self): + """ + Return the last tuned RD. Initially this is the starting value + """ + return self._autotune_baseline + + + # ---------------------------- Internal Impl ----------------------------- + + + def _recommend_rd_from_ekf_path(self, d_ext, dt_s): + """ + Autotune baseline RD using EKF path statistics gathered near neutral. + Returns: Tuple (rec_rd|None, note|None) + """ + cfg = self.ctrl.cfg + + # Stability tests near neutral + stability_test = abs(self.ctrl.state.x) < cfg.autotune_stable_x_thresh + + # Accrue stable time/motion + move = abs(d_ext) + if stability_test: + self._stable_time += dt_s + self._stable_motion_mm += move + + # if move == 0.0: leave EMA unchanged this tick + if move > 0.0: + L = max(1e-9, cfg.autotune_var_len_mm) + alpha = 1.0 - math.exp(-move / L) + + # --- EMA in SPEED space: v = 1 / rd_current --- + rd_curr = max(1e-9, float(self.ctrl.rd_current)) + v = 1.0 / rd_curr + + if self._rd_ema_mean is None: + # Seed on first accepted sample (now in speed space) + self._rd_ema_mean = v + self._rd_ema_var = 0.0 + else: + # EWMA mean + West's EW variance, in speed space + m_prev = self._rd_ema_mean + d = v - m_prev + m_new = m_prev + alpha * d + v_new = (1.0 - alpha) * (self._rd_ema_var + alpha * d * d) + + self._rd_ema_mean = m_new + self._rd_ema_var = max(0.0, v_new) + + else: + # Leaving stable test -> drop stats so we don't carry junk + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + self._rd_ema_mean = None + self._rd_ema_var = 0.0 + + if self._rd_ema_mean is None: + return None, None + + time_ok = (self._stable_time >= cfg.autotune_stable_time_s) + motion_ok = (self._stable_motion_mm >= (cfg.autotune_motion_mm or 0.0)) + if cfg.autotune_basis == "time": + ready = time_ok + elif cfg.autotune_basis == "motion": + ready = motion_ok + elif cfg.autotune_basis == "either": + ready = time_ok or motion_ok + else: + ready = time_ok and motion_ok + if not ready: + return None, None + + # --- Interpret EMA as SPEED stats, then map back to RD --- + mean_v = max(self._rd_ema_mean, 1e-12) # mean speed + var_v = max(0.0, self._rd_ema_var) # variance of speed + + # Speed-relative variance test: std(speed)/mean(speed) ≤ f + f = cfg.autotune_var_rel_frac + std_v = math.sqrt(var_v) + rel_std_v = std_v / mean_v if mean_v > 0 else float("inf") + if rel_std_v > f: + # Convert mean_v back to rd for reporting + mean_rd_for_note = 1.0 / mean_v + note = "Rejected rd {:.4f} due to speed-relative variance {:.4f} > {:.4f}".format(mean_rd_for_note, rel_std_v, f) + return None, note + + # Potential new candidate: + # Convert mean speed back to an rd estimate + mean_rd = 1.0 / mean_v + note = u"EKF logic suggests rd≈{:.4f} after {:.1f}s/{:.1f}mm near neutral".format(mean_rd, self._stable_time, self._stable_motion_mm) + return mean_rd, note + + + def _recommend_rd_from_twolevel(self): + """ + Minimal statistical baseline update for two-level mode for CO/TO sensor types or + optionally P/D types if configured in twolevel mode. + Returns: Tuple (rec_rd|None, note|None) + """ + cfg = self.ctrl.cfg + + # Only evaluate *on flips* (state changes) + if self._tl_updates_since_flip != 0: + return None, None + + # Require min segment samples per state and min total cycles + n_low = len(self._tl_samples_low) + n_high = len(self._tl_samples_high) + n_cycles = len(self._tl_cycles) + if n_low < self._tl_min_cycles or n_high < self._tl_min_cycles or n_cycles < self._tl_min_cycles: + return None, None + + # Compute per-cycle fractions for variance (fh_list) and ratio-of-sums for mean duty + fh_list = [] + dl_sum = 0.0 + dh_sum = 0.0 + for (dl, dh) in self._tl_cycles[-self._tl_cycle_window:]: + tot = max(1e-12, dl + dh) + fh_list.append(dh / tot) # per-cycle fraction (for variance) + dl_sum += dl + dh_sum += dh + + if not fh_list: + return None, None + + # Duty (mean) via ratio-of-sums + tot_sum = max(1e-12, dl_sum + dh_sum) + fh_mean = dh_sum / tot_sum + + # Duty-weighted *speed* estimate, then map back to RD to remove RD-space bias + v_low = 1.0 / max(1e-9, self.ctrl.rd_low) + v_high = 1.0 / max(1e-9, self.ctrl.rd_high) + v_est = (1.0 - fh_mean) * v_low + fh_mean * v_high + rd_est = 1.0 / max(1e-9, v_est) + + # rd_est significance test via z-score from variability of fh across cycles to see if it + # statistically distinguishable from the baseline given the observed variability + z = None + if cfg.autotune_significance_z > 0.0 and len(fh_list) >= 2: + # Sample std of fh across cycles + mu = sum(fh_list) / float(len(fh_list)) + var_f = sum((f - mu) ** 2 for f in fh_list) / float(max(1, len(fh_list) - 1)) + std_f = math.sqrt(var_f if var_f > 0.0 else 0.0) + se_f = (std_f / math.sqrt(len(fh_list))) if len(fh_list) > 0 else float("inf") + + # Propagate fh uncertainty through rd = 1 / ((1-f)/rd_low + f/rd_high) + # drd/df = rd^2 * (1/rd_low - 1/rd_high) = rd^2 * (v_low - v_high) + sensitivity = (rd_est ** 2) * abs(v_low - v_high) + se_rd = sensitivity * se_f # Std error in rd + + if se_rd >= 1e-9: + z = abs(rd_est - self._autotune_current) / se_rd + if z < float(cfg.autotune_significance_z): + note = ("Rejected rd {:.4f} because z-score {:.2f} not significant (<{:.2f})").format(rd_est, z, cfg.autotune_significance_z) + return None, note + # else: se_rd ~ 0 => treat as pass (perfect/no-variance case) + + # Potential new candidate + score = ("%.2f" % z) if z is not None else "perfect" + note = (u"Two-level logic suggests rd≈{:.4f} (duty {:.2f} over {} cycles, z-score={})").format(rd_est, fh_mean, len(fh_list), score) + return rd_est, note + + + def _certainty_score(self, samples, tau_rel=0.01, n0=3.0, eps=1e-12): + """ + Certainty in [0,1]. Higher = more certain. + - tau_rel: target relative SE; smaller => stricter (e.g., 0.01 = 1%) + - n0: prior sample penalty; larger => more skepticism with small n + Returns: (score, mean, se, n) + """ + vals = [float(v) for v in samples if v is not None] + n = len(vals) + if n == 0: + return 0.0, None, None, 0 + + m = sum(vals) / float(n) + + if n >= 2: + mean_sq = sum(v*v for v in vals) / float(n) + var = max(0.0, mean_sq - m*m) * n / float(max(1, n - 1)) # unbiased + s = math.sqrt(var) + else: + s = 0.0 + + se = s / math.sqrt(n) if n > 0 else float('inf') + rel_se = se / max(abs(m), eps) if m != 0.0 else float('inf') + + # Precision shrinks as relative SE grows; bounded (0,1] + prec = 1.0 / (1.0 + (rel_se / max(tau_rel, eps))) + + # Sample-size prior; bounded [0,1) + size = n / (n + float(n0)) + + # Enforce "n<2 -> effectively no certainty" + if n < 2: + prec = 0.0 + + score = prec * size + return score, m, se, n + + def _frac_speed_delta(self, rd_new, rd_ref): + # |v(new)-v(ref)| / v(ref) with v = 1/rd => |rd_ref/rd_new - 1| + return abs((rd_ref / max(1e-9, rd_new)) - 1.0) + + def _autotune_confident(self, rec_rd): + """ + All speed-relative: + - min fractional speed delta vs last saved value + Returns: tuple(True|False, reason) + """ + cfg = self.ctrl.cfg + + # Require ever increasing certainty score (std error + n) + tau_rel = cfg.autotune_cert_tau_rel + n0 = cfg.autotune_cert_n0 + hyster = cfg.autotune_cert_hysteresis + + # Push proposal and score the window + self._rd_cert_fifo.append(rec_rd) + score, mean, se, n = self._certainty_score(self._rd_cert_fifo, tau_rel=tau_rel, n0=n0) + prev = self._rd_cert_last_score + threshold = 0 if prev == 0 else max(prev + hyster, 0) + improved = score > threshold + + if not improved: + if prev < 0: + self._rd_cert_last_score = 0. + note = "Rejected new rd {:.4f} due to certainty score of zero (n={})".format(rec_rd, n) + else: + note = "Rejected new rd {:.4f} due to certainty score {:.3f} ≤ prev {:.3f} (n={})".format(rec_rd, score, prev, n) + return None, note + + self._rd_cert_last_score = score + note = "with certainty score of {:.3f} (prev {:.3f}), n={}, mean {:.4f}, SE {:.4f}".format( + score, prev, n, mean if mean is not None else float('nan'), se if se is not None else float('nan')) + return mean, note + +# ----------------------------- Flowguard Engine ------------------------- + +class _FlowguardEngine(object): + """ + Encapsulates FlowGuard state and logic. Determines based on total filament movement + and amount of rd correction applied if a clog or tangle is likely to have occurred. + A reason string explains the reason for the trigger. + A single update_flowguard() entry point to be called on each tick. + """ + + def __init__(self, ctrl): + self.ctrl = ctrl + self.reset() + + # -------------------------------- API ----------------------------------- + + def reset(self): + # Accumulators + self._comp_motion_mm = 0.0 + self._tens_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + self._relief_tens_mm = 0.0 + + # Condition monitoring + self._trigger = "" + self._reason = "" + self._level = 0.0 + self._max_clog = 0.0 + self._max_tangle = 0.0 + self._relief_headroom = 0.0 # Debugging + + # FlowGuard arming test + self._armed = False # Disarmed until a state change while moving or verified near neutral + self._arm_motion_mm = 0.0 # Motion since last (or initial) state sample + self._arm_last_state = None + + def update_flowguard(self, d_ext, sensor_reading): + """ + Distance-based FlowGuard with symmetric handling for one-sided switches. + + - For P/D sensors: + Uses controller._extreme_flags() on the sensor reading. + - For CO/TO sensors: + Uses the sensor directly for the *seen* side, and infer the unseen side + CO (compression-only): unseen = TENSION; relief effort is COMPRESSION (delta_rel > 0) + TO (tension-only) : unseen = COMPRESSION; relief effort is TENSION (delta_rel < 0) + Returns: Status Dict {"trigger", "reason", ...} + """ + cfg = self.ctrl.cfg + effort = self._relief_effort(d_ext) # +ve => compression effort, -ve => tension effort + + # Get the current sensor state for FlowGuard purposes (CO/TO are always at extreme) + state_now = self._flowguard_polarity(sensor_reading) + comp_ext, tens_ext = (state_now == 1, state_now == -1) + + # Arming logic to prevent false triggers on startup if thresholds are tight + self._arm_motion_mm += d_ext + state_now = self.ctrl._extreme_polarity(sensor_reading) + if self._arm_last_state is None: + self._arm_last_state = state_now + + self._relief_headroom = cfg.flowguard_relief_mm + + if not self._armed: + # Arm when we've moved and observed any change in coarse state or know we are near neutral + changed_state = (state_now != self._arm_last_state) + moved = abs(self._arm_motion_mm) > 0.0 + near_neutral = cfg.sensor_type in ("P") and abs(sensor_reading) < cfg.autotune_stable_x_thresh + if moved and (changed_state or near_neutral): + self._armed = True + else: + return self.status() + self._arm_last_state = state_now + + if comp_ext: # Extreme Compression + self._comp_motion_mm += d_ext + + # Relief for compression is *tension* effort (delta_rel < 0) + if effort < 0: + self._relief_comp_mm += (-effort) + + comp_relief_trig = (abs(self._relief_comp_mm) >= cfg.flowguard_relief_mm) + self._relief_headroom -= self._relief_comp_mm + + if comp_relief_trig and not self._trigger: + self._trigger = "clog" + self._reason = "Compression stuck after %.2f mm motion and %.2f mm relief (triggering parameter: flowguard_max_relief)" % ( + self._comp_motion_mm, self._relief_comp_mm + ) + + # Maintain normalized [0..1] clog headroom marker + mcr = abs(self._relief_comp_mm / cfg.flowguard_relief_mm) + c_level = min(1.0, mcr) + self._level = c_level + if c_level > self._max_clog: + self._max_clog = c_level + + # Reset the tension side when compression extreme is active + self._tens_motion_mm = 0.0 + self._relief_tens_mm = 0.0 + + elif tens_ext: # Extreme Tension + self._tens_motion_mm += d_ext + + # Relief for tension is *compression* effort (delta_rel > 0) + if effort > 0: + self._relief_tens_mm += effort + + tens_relief_trig = (abs(self._relief_tens_mm) >= cfg.flowguard_relief_mm) + self._relief_headroom -= self._relief_tens_mm + + if tens_relief_trig and not self._trigger: + self._trigger = "tangle" + self._reason = "Tension stuck after %.2f mm motion and %.2f mm relief (triggering parameter: flowguard_max_relief)" % ( + self._tens_motion_mm, self._relief_tens_mm + ) + + # Maintain normalized [0..-1] tangle headroom marker + mtr = -abs(self._relief_tens_mm / cfg.flowguard_relief_mm) + t_level = max(-1.0, mtr) + self._level = t_level + if self._level < self._max_tangle: + self._max_tangle = t_level + + # Reset the compression side when tension extreme is active + self._comp_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + + else: # No extreme: reset both sides + self._comp_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + self._tens_motion_mm = 0.0 + self._relief_tens_mm = 0.0 + + return self.status() + + def status(self): + s = { + "active": self._armed, + "level": self._level, + "max_clog": self._max_clog, + "max_tangle": self._max_tangle, + "trigger": self._trigger, + "reason": self._reason, + } + + # When debug logging + if self.ctrl.cfg.log_sync: + s.update({ + "relief_headroom": self._relief_headroom, + }) + + return s + + def _relief_effort(self, d_ext): + """ + Signed relief 'effort' this tick (mm-equivalent). + Positive => compression effort, negative => tension effort. + + Baseline is autotune._autotune_current, i.e. the *tuned* RD: + - In twolevel mode, this matches the rd_ref we recenter around. + - In EKF mode, this is the learned "true" RD, even if rd_ref remains the + originally persisted value. + """ + rd_ref = self.ctrl.autotune.get_rec_rd() # Starts at self.ctrl.rd_ref + rd_cur = self.ctrl.rd_current + if abs(rd_cur) < 1e-9: + return 0.0 + return d_ext * ((rd_ref / rd_cur) - 1.0) + + def _flowguard_polarity(self, sensor_reading): + """ + FlowGuard-only coarse polarity. + + For CO/TO, treat OPEN as an extreme on the unseen side so FlowGuard tracks immediately: + CO: z==1 -> +1 (compression), z==0 -> -1 (tension-as-open) + TO: z==-1 -> -1 (tension), z==0 -> +1 (compression-as-open) + + For P/D, defer to controller polarity. + """ + cfg = self.ctrl.cfg + if cfg.sensor_type == "CO": + return 1 if int(sensor_reading) == 1 else -1 + if cfg.sensor_type == "TO": + return -1 if int(sensor_reading) == -1 else 1 + return self.ctrl._extreme_polarity(sensor_reading) + + +# -------------------------- Controller Core ---------------------------- + +class SyncController(object): + """ + Movement-triggered filament tension controller. + + update(eventtime, extruder_delta_mm, sensor_reading): + - Propagates EKF with motion & measurement + - Computes desired effective gear motion to pull x→0 (PD with derivative if Type-P) + - Converts to RD target via configurable gear mapping (symmetric/asymmetric), then distance-smoothed + - Relief-biased snap when sensor pegged + - Neutral trim near zero + - FlowGuard detection + - Autotune of baseline RD (time/motion near neutral, or two-level duty estimator) + """ + + def __init__(self, cfg, c0= 1.0, x0=None): + self.cfg = cfg + self._set_twolevel_active() + + self._tick = 0 + self._last_time_s = None + self._log_ready = False + + self.K = 2.0 / cfg.buffer_range_mm # mm => normalized delta in x + self.state = EKFState() + self.state.c = max(cfg.c_min, min(cfg.c_max, c0)) + if x0 is not None: + self.state.x = max(-1.0, min(1.0, x0)) + + rd_init = float(cfg.rd_start) + self.rd_current = rd_init # Current rd in effect + self.rd_ref = rd_init # Last "tuned" rd + + # Allows initial wider range of rd until first autotune candidate + self._twolevel_boost_active = True + + self._twolevel_hys_state = 0 # -1, 0, +1 (last hysteretic extreme for type-P) + + # Set absolute limits for rd range + self._set_min_max_rd(rd_init) + + # Readiness (lag-aware) + self._mm_since_info = 0.0 + self._last_info_z = None + + # UI visualization + self._vis_est = 0.0 + + # Two-level flip-flop state (reused for CO/TO and optional P/D) + self._os_target_level = "low" # "low" or "high" + self._os_since_flip_mm = 0.0 + + # FlowGuard engine (encapsulates non-shared FlowGuard state/logic) + self.flowguard = _FlowguardEngine(self) + + # Autotune helper (encapsulates all autotune state/logic) + self.autotune = _AutotuneEngine(self) + + + # ------------------------------------ PUBLIC API ------------------------------------ + + def reset(self, eventtime, rd_init, sensor_reading, log_file=None, hard_reset=True, simulation=False): + """ + Full controller reset for a gear motor swap or new cold start. + Seeds internal time to `t_s` and zeroes elapsed time. + """ + cfg = self.cfg + self._set_twolevel_active() + + self._log_ready = False + self._current_log_file = log_file or cfg.log_file + + # Rotation distance & baseline (always rebase) + self.rd_current = rd_init + self.rd_ref = rd_init + self._twolevel_boost_active = True + self._set_min_max_rd(rd_init) + + # Seed x_hat from sensor reading + if cfg.sensor_type == "P": + z = float(sensor_reading) + x0 = max(-1.0, min(1.0, z)) + self._twolevel_hys_state = int(math.copysign(1, x0)) if abs(x0) >= 1e-6 else 0 + + else: + z = int(sensor_reading) + z = 1 if z > 0 else (-1 if z < 0 else 0) + x0 = float(z) + + # EKF state & covariance + if cfg.sensor_type == "P" and not self.twolevel_active: + self.state.x = float(x0) + self.state.x_prev = self.state.x + self.state.c = 1.0 + self.state.P11 = 0.5 + self.state.P12 = 0.0 + self.state.P22 = 0.2 + + # Readiness (lag-aware) + self._mm_since_info = 0.0 + self._last_info_z = float(x0) if cfg.sensor_type == "P" else int(round(x0)) + + # Time (for update()) + self._tick = 0 + self._last_time_s = eventtime + + # For UI + self._vis_est = float(sensor_reading) + + # Two-level init for CO/TO + if self.cfg.sensor_type in ("CO", "TO"): + in_contact0 = self._onesided_contact(sensor_reading) + if self.cfg.sensor_type == "CO": + self._os_target_level = "high" if in_contact0 else "low" + else: + self._os_target_level = "low" if in_contact0 else "high" + self._os_since_flip_mm = 0.0 + + # Two-level init for P/D (optional) + if self.cfg.sensor_type in ("P", "D") and self.twolevel_active: + pol0 = self._extreme_polarity(sensor_reading) + if pol0 > 0: + self._os_target_level = "high" + elif pol0 < 0: + self._os_target_level = "low" + else: + self._os_target_level = "low" # neutral start; will flip on first extreme + self._os_since_flip_mm = 0.0 + + if hard_reset: + # Rebase autotune helper on the new start + self.autotune.restart(rd_init) + + # Reset FlowGuard engine state on controller reset + self.flowguard.reset() + + # Setup special json debug log + if self.cfg.log_sync: + self._init_log() + + return self.update(eventtime, 0.0, sensor_reading, simulation=simulation) + + + def update(self, eventtime, extruder_delta_mm, sensor_reading, simulation=False): + """ + Required absolute timestamp `t_s` (seconds). Internally computes dt from the + last call (or reset). The timestamp must be monotonic non-decreasing. + """ + cfg = self.cfg + + if self._last_time_s is None: + self._last_time_s = eventtime + + if extruder_delta_mm < 0: + self.autotune.pause() + else: + self.autotune.resume() + + # Compute dt and advance time cursors + dt_s = max(0.0, float(eventtime - self._last_time_s)) # Protect against non-monotonic clock + self._last_time_s = eventtime + d_ext = float(extruder_delta_mm) + + rd_prev = self.rd_current + rd_note = None + d_gear = self._gear_mm_from_rd(d_ext, rd_prev) + + if self.twolevel_active: + # ------------------- TWO-LEVEL BRANCH ------------------ + + # FlowGuard update + flowguard_out = self.flowguard.update_flowguard(d_ext, int(sensor_reading) if cfg.sensor_type in ("CO", "TO", "D") else sensor_reading) + + # Determine immediate RD target from two-level rules + prev_level = self._os_target_level # Capture before helper changes (detect flips) + rd_target = self._twolevel_rd_target(rd_prev, d_ext, sensor_reading) + flipped_this_tick = (self._os_target_level != prev_level) + + # Delegate two-level evidence collection to autotune helper + if cfg.sensor_type in ("CO", "TO"): + extreme_active = self._onesided_contact(sensor_reading) + else: + extreme_active = (self._extreme_polarity(sensor_reading) != 0) + self.autotune.note_twolevel_tick(self._os_target_level, flipped_this_tick, d_ext, extreme_active) + + else: + # -------------------- KALMAN BRANCH -------------------- + + self._ekf_predict(extruder_mm=d_ext, gear_mm=d_gear) + self._ekf_update(float(sensor_reading)) + + # FlowGuard update + flowguard_out = self.flowguard.update_flowguard(d_ext, sensor_reading) + + # Compute immediate RD target + desired_eff = self._desired_effective_gear_mm(d_ext, dt_s) # = c_hat * u_des + c_hat = max(cfg.c_min, min(cfg.c_max, self.state.c)) + u_des = desired_eff / c_hat + rd_target = self._rd_from_desired_gear_mm(d_ext, u_des) + if rd_target is None: + rd_target = rd_prev # no extruder motion; hold RD + + # Relief-biased snap at extremes (guaranteed relief per update) + comp_ext, tens_ext = self._extreme_flags(sensor_reading) + if cfg.snap_at_extremes and d_ext != 0.0 and (comp_ext or tens_ext): + zsign = 1 if comp_ext else -1 # +1 compression, -1 tension + relief_frac = max(0.05, min(0.60, float(cfg.extreme_relief_frac))) + rd_ref = self.rd_ref + + # Derived from: delta_rel = d_ext * (c_hat * rd_ref / rd - 1) + sgn = 1.0 if d_ext > 0 else -1.0 + denom = 1.0 - (sgn * zsign) * relief_frac + denom = max(0.05, denom) + rd_target = (c_hat * rd_ref) / denom + rd_note = "Relief-biased snap at extreme" + + # Smooth target + rd_clamped = self._clamp_to_envelope(rd_target) + rd_target = self._smooth_rd_by_distance(rd_prev, rd_clamped, d_ext, sensor_reading=sensor_reading) + + # ------------- SHARED -------------- + + # Now clamp and apply the newly decided RD for future motion + rd_applied = self._clamp_to_envelope(rd_target) + if not _isclose(self.rd_current, rd_applied, abs_tol=1e-12): + self.rd_current = rd_applied + + # Update UI helper + sensor_expected = self._expected_sensor_reading(sensor_reading) + + # Autotune decision + autotune_out = self.autotune.update_autotune(d_ext, dt_s, report_trivial=self._twolevel_boost_active) + auto_rd = autotune_out.get('rd') + if auto_rd is not None: + if self.twolevel_active: + # Only adjust RD reference point if in twolevel mode to "center switching" + self.rd_ref = auto_rd + + # Reset boost (twolevel rd high/low) after first autotune candidate + self._twolevel_boost_active = False + self._set_low_high_rd(auto_rd) + + if flowguard_out.get('trigger'): + self.autotune.restart(self.rd_ref) + + # Essential output + out = { + "output": { + "rd_prev": rd_prev, + "rd_current": self.rd_current, + "rd_tuned": self.autotune.get_tuned_rd(), # What autotune believes to be accurate + "sensor_ui": sensor_expected, + "flowguard": flowguard_out, # Keys: "trigger", "reason", "level", "max_clog", "max_tangle", "active" + "autotune": autotune_out, # Keys: "rd", "note", "save" + } + } + + # Additional debug/logging info + if cfg.log_sync or simulation: + out["output"].update({ + "rd_target": rd_target, # Unclamped target on which rd_current is based + "rd_ref": self.rd_ref, # What EKF or twolevel logic is using as baseline + "rd_note": rd_note, + "x_est": self.state.x, + "c_est": self.state.c + }) + out["input"] = { + "tick": self._tick, + "t_s": eventtime, + "dt_s": dt_s, + "d_mm": extruder_delta_mm, + "sensor": sensor_reading + } + + if cfg.log_sync: + self._append_log_entry(out) + + self.state.x_prev = self.state.x + self._tick += 1 + return out + + + def polarity(self, sensor_reading): + return self._extreme_polarity(sensor_reading) + + + def get_type_mode(self): + sensor_type = self.cfg.sensor_type + if sensor_type == 'P': + sensor_type += " (TwoLevel mode)" if self.twolevel_active else " (EKF mode)" + return sensor_type + + + def get_current_rd(self): + """ + Return the current RD in use + """ + return self.rd_current + + + # --------------------------------- Internal Impl ------------------------------------ + + def _set_twolevel_active(self): + """ + Twolevel mode is updated on each reset to allow responsive behavior to sensor disable + """ + self.twolevel_active = ( + self.cfg.sensor_type in ("CO", "TO", "D") + or (self.cfg.sensor_type == "P" and self.cfg.use_twolevel_for_type_p is True) + ) + + + def _set_min_max_rd(self, rd): + """ + Set absolute immutable min/max rd "speeds" + """ + f_minmax = max(0.0, min(0.99, self.cfg.rd_min_max_speed_multiplier)) + + self.rd_min = rd / (1.0 + f_minmax) + self.rd_max = rd / (1.0 - f_minmax) + self._set_low_high_rd(rd) # Also set current low/high in effect + + + def _set_low_high_rd(self, rd): + """ + Set high/low rd "speeds" + """ + f_minmax = max(0.0, min(0.99, self.cfg.rd_min_max_speed_multiplier)) + if self.twolevel_active: + f_norm = self.cfg.rd_twolevel_speed_multiplier + f_boost = self.cfg.rd_twolevel_boost_multiplier if self._twolevel_boost_active else 0.0 + f = max(0.0, min(f_minmax, (f_norm + f_boost))) + else: + f = f_minmax + + self.rd_low = rd / (1.0 + f) + self.rd_high = rd / (1.0 - f) + + + def _clamp_to_envelope(self, rd): + """ + Never allow rd outside of limits + """ + return max(self.rd_min, min(self.rd_max, rd)) + + # -------------- Mapping helpers ----------------- + + def _gear_mm_from_rd(self, d_ext, rd): + """ + Map RD -> effective gear motion for this update. + Asymmetric mapping: + forward (d_ext > 0): u = d_ext * (rd_ref / rd) + retract (d_ext < 0): u = d_ext * (rd / rd_ref) + """ + rd_ref = self.rd_ref + d_ext = float(d_ext) + if abs(d_ext) < 1e-12: + return 0.0 + + if d_ext > 0.0: + scale = rd_ref / max(1e-9, rd) + else: + scale = max(1e-9, rd) / rd_ref + + return d_ext * scale + + def _rd_from_desired_gear_mm(self, d_ext, u_des): + """ + Invert the asymmetric mapping to get the RD target from desired effective gear motion. + Enforces no in-step reversal: u_des * d_ext must be > 0. + """ + rd_ref = self.rd_ref + d_ext = float(d_ext) + if abs(d_ext) < 1e-12: + return None + + # Prevent reversal within an update + if u_des * d_ext <= 0.0: + return self.rd_high if d_ext > 0 else self.rd_low + + if d_ext > 0.0: + # u = d_ext * (rd_ref / rd) => rd = rd_ref * d_ext / u + denom = u_des if abs(u_des) > 1e-12 else (1e-12) + rd = rd_ref * d_ext / denom + else: + # u = d_ext * (rd / rd_ref) => rd = (u * rd_ref) / d_ext + # (note: d_ext < 0 so division preserves sign correctly) + rd = (u_des * rd_ref) / d_ext + + return rd + + # --------------------- EKF ---------------------- + + def _ekf_predict(self, extruder_mm, gear_mm): + """ + Predict with the RD actually used last update (rd_prev): + """ + s, cfg = self.state, self.cfg + x_pred = s.x + self.K * (s.c * gear_mm - extruder_mm) + c_pred = s.c + + F11 = 1.0 + F12 = self.K * gear_mm + F21 = 0.0 + F22 = 1.0 + + P11, P12, P22 = s.P11, s.P12, s.P22 + FP11 = F11*P11 + F12*P12 + FP12 = F11*P12 + F12*P22 + FP21 = F21*P11 + F22*P12 + FP22 = F21*P12 + F22*P22 + + s.P11 = FP11*F11 + FP12*F12 + cfg.q_x + s.P12 = FP11*F21 + FP12*F22 + s.P22 = FP21*F21 + FP22*F22 + cfg.q_c + + s.x = max(-1.25, min(1.25, x_pred)) # Soft clamp in estimate space + s.c = max(cfg.c_min, min(cfg.c_max, c_pred)) + + + def _ekf_update(self, z): + s, cfg = self.state, self.cfg + z = max(-1.0, min(1.0, float(z))) + R = cfg.r_type + y = z - s.x + S = s.P11 + R + if S <= 0: + return + Kx = s.P11 / S + Kc = s.P12 / S + s.x += Kx * y + s.c += Kc * y + s.c = max(cfg.c_min, min(cfg.c_max, s.c)) + s.P22 -= (s.P12 * Kc) + s.P12 *= (1 - Kx) + s.P11 *= (1 - Kx) + + + # ----------- Sensor reading helpers ---------- + + def _onesided_contact(self, sensor_reading): + """ + True if the one-sided sensor is in-contact (triggered) + """ + cfg = self.cfg + + if cfg.sensor_type == "CO": + return int(sensor_reading) == 1 + + if cfg.sensor_type == "TO": + return int(sensor_reading) == -1 + + return False + + + def _extreme_polarity(self, sensor_reading): + """ + Reduce sensor to a coarse (extreme) states + P : +1 if ≥ threshold, -1 if ≤ -threshold, else 0 + D : {-1,0,+1} as-is + CO: {0, +1} as-is + TO: {-1, 0} as-is + Normally this is the flowguard threshold but the P/D twolevel is + usually a slightly lesser test + """ + cfg = self.cfg + if cfg.sensor_type != "P": + return int(sensor_reading) + + z = float(sensor_reading) + if not self.twolevel_active: + thr = cfg.flowguard_extreme_threshold + return 1 if z >= thr else -1 if z <= -thr else 0 + + # Add hysteresis on twolevel extreme for type-P sensor + hi = abs(float(cfg.p_twolevel_threshold)) + lo = max(0.0, hi - cfg.p_twolevel_hysteresis) + s = self._twolevel_hys_state + + if s != 0: + if s * z <= lo: + s = 0 + else: + s = (z >= hi) - (z <= -hi) + + self._twolevel_hys_state = s + return s + + + def _is_extreme(self, sensor_reading): + """ + True if current reading is pegged per sensor type + """ + return self._extreme_polarity(sensor_reading) != 0 + + + def _extreme_flags(self, sensor_reading): + """ + Return (compression_extreme, tension_extreme) + """ + p = self._extreme_polarity(sensor_reading) + return (p == 1, p == -1) + + # -------------- Twolevel helpers ------------- + + def _twolevel_rd_target(self, rd_prev, d_ext, sensor_reading): + """ + CO/TO: Pure two-level control. + - CO: open -> rd_low (seek compression), contact -> rd_high (relieve) + - TO: open -> rd_high (seek tension), contact -> rd_low (relieve) + Uses a small hysteresis on motion (os_min_flip_mm) so we don't chatter. + Returns the desired RD target for this update (before smoothing/rate limiting). + + P/D: Two-level mode (optional via config). + - Flip only at extremes (neutral band does not change RD). + - Compression extreme -> rd_high; Tension extreme -> rd_low. + """ + cfg = self.cfg + self._os_since_flip_mm += d_ext + + if cfg.sensor_type in ("CO", "TO"): + # Desired level from current contact state + in_contact = self._onesided_contact(sensor_reading) + if cfg.sensor_type == "CO": + desired_level = "high" if in_contact else "low" + else: # "TO" + desired_level = "low" if in_contact else "high" + + else: + # Desired level from polarity + pol = self._extreme_polarity(sensor_reading) # {+1, -1, 0} + if pol == 0: + desired_level = self._os_target_level + else: + desired_level = "high" if (pol > 0) else "low" + + # Flip only if we've moved enough since the last flip + if desired_level != self._os_target_level and abs(self._os_since_flip_mm) >= cfg.os_min_flip_mm: + self._os_target_level = desired_level + self._os_since_flip_mm = 0.0 + + # Map level to RD + rd_target = self.rd_low if self._os_target_level == "low" else self.rd_high + + return rd_target + + # ----------------- EKF helpers --------------- + + def _desired_effective_gear_mm(self, d_ext, dt_s): + s, cfg = self.state, self.cfg + dead = max(0.0, cfg.ctrl_deadband) + x = s.x + x_ctrl = 0.0 if abs(x) < dead else (x - math.copysign(dead, x)) + + kd_eff = cfg.kd if dt_s > 0 else 0.0 + dx = (s.x - s.x_prev) / max(1e-9, dt_s) if kd_eff != 0.0 else 0.0 + + return d_ext - cfg.kp * x_ctrl - kd_eff * dx + + + def _smooth_rd_by_distance(self, rd_prev, rd_target, d_ext, sensor_reading=None): + """ + Glide the current RD towards target using a fixed rd_filter_len_mm motion length + respecting readiness factor and extreme limits. + """ + move = abs(float(d_ext)) + + # Exponential smoothing for soft glide from rd_prev toward rd_target. + # Bigger move or higher r => bigger step. + L = max(1e-9, self.cfg.rd_filter_len_mm) + alpha_base = 1.0 - math.exp(-move / L) + r = self._update_readiness_and_get_r(sensor_reading, move) if sensor_reading is not None else 1.0 + alpha = r * alpha_base + + rd_filtered = rd_prev + alpha * (rd_target - rd_prev) + + # Rate limit (with extreme multiplier) + is_extreme = self._is_extreme(sensor_reading) if sensor_reading is not None else False + if self.cfg.rd_rate_per_mm is not None and move > 0: + rate_mult = (self.cfg.rate_extreme_multiplier if is_extreme else 1.0) + max_step = abs(self.cfg.rd_rate_per_mm) * move * r * rate_mult + rd_delta = rd_filtered - rd_prev + if rd_delta > max_step: + rd_filtered = rd_prev + max_step + elif rd_delta < -max_step: + rd_filtered = rd_prev - max_step + + return rd_filtered + + + def _update_readiness_and_get_r(self, sensor_reading, move_abs_mm): + """ + Returns (lag-aware) readiness value for 0=not ready, to 1=ready now + The purpose is so that we don’t react fully until we’ve seen enough + motion or a meaningful sensor change. + P-EKF only + """ + cfg = self.cfg + if cfg.sensor_lag_mm <= 0: + r = 1.0 + else: + self._mm_since_info += move_abs_mm + z = float(sensor_reading) + if self._last_info_z is None or abs(z - self._last_info_z) >= cfg.info_delta_a: + self._last_info_z = z + self._mm_since_info = 0.0 + L = max(1e-6, cfg.sensor_lag_mm) + r = max(0.0, min(1.0, self._mm_since_info / L)) + + if self._is_extreme(sensor_reading): + r = max(r, cfg.readiness_extreme_floor) + return r + + + def _expected_sensor_reading(self, sensor_reading): + """ + UI helper for prediction of idealized sensor reading. + Returns a float in [-1, 1] depending on sensor type. + """ + cfg = self.cfg + + # Type P: always passthrough true sensor reading + if cfg.sensor_type == "P": + self._vis_est = sensor_reading + return self._vis_est + + # Snap to extremes for D/CO/TO when pegged: + if self._is_extreme(sensor_reading): + self._vis_est = float(self._extreme_polarity(sensor_reading)) + return self._vis_est + + # Get phase info + ph = self.autotune.twolevel_phase(exclude_extreme=cfg.sensor_type == "D") + + # Snap to extreme if no phase info available + if ph is None: + self._vis_est = float(self._extreme_polarity(sensor_reading)) + return self._vis_est + + phase, level, extruding = ph + + # Type CO/TO: Split phase to encompass rebound + if cfg.sensor_type in ['CO', 'TO']: + + def triangle_half(p, lo=0.3, hi=0.8): + base = (1.0 - 2.0*p) if p <= 0.5 else (2.0*p - 1.0) # in [0,1] + return lo + (hi - lo) * base + + if cfg.sensor_type == "CO": + self._vis_est = triangle_half(phase) + else: + self._vis_est = -triangle_half(phase) + + return self._vis_est + + # Type D: Adjust phase to exclude extreme portion + def triangle_full(p, lo=-0.9, hi=0.9): + return lo + (hi - lo) * p + + t = triangle_full(phase) + if level == "low": + x_pred = t if extruding else -t # Ramping up if extruding + else: + x_pred = -t if extruding else t # Ramping down if extruding + self._vis_est = x_pred + return self._vis_est + + + # -------------- Logging helpers -------------- + + def _init_log(self, log_file=None): + """ + (Re)create the log file and write a single header entry. + Clears any existing file. + """ + header = { + "header": { + "rd_start": self.cfg.rd_start, + "sensor_type": self.cfg.sensor_type, + "twolevel_active": self.twolevel_active, + "buffer_range_mm": self.cfg.buffer_range_mm, + "buffer_max_range_mm": self.cfg.buffer_max_range_mm, + } + } + + with io.open(self._current_log_file, "a", encoding="utf-8") as f: + json.dump(header, f, ensure_ascii=False) + f.write("\n") + self._log_ready = True + + + def _append_log_entry(self, record): + """ + Append a single JSON object to the log as one line. + """ + if not self._log_ready: + return + + with io.open(self._current_log_file, "a", encoding="utf-8") as f: + json.dump(record, f, ensure_ascii=False) + f.write("\n") diff --git a/extras/mmu/mmu_sync_controller.py3 b/extras/mmu/mmu_sync_controller.py3 new file mode 100644 index 000000000000..65451e573650 --- /dev/null +++ b/extras/mmu/mmu_sync_controller.py3 @@ -0,0 +1,1659 @@ +# -*- coding: utf-8 -*- +# +# Happy Hare MMU Software +# Sync Feedback Controller +# +# This helper module implements a motion-triggered filament tension controller — that adapts gear +# stepper rotation distance (RD) dynamically based on sensor feedback. It offers modes of operation: +# +# 1) Simple dual level RD selection that works with CO (Compression only switch), +# TO (Tension only switch), and optionally with D (Dual switch) or P (Proportional) sensors. +# +# 2) Combined proportional-derivative (PD) controller with Extended Kalman Filter +# (EKF) for optimal results with P (Proportional) sensor. +# +# Flowguard: It also implements protection for all modes/sensor types that will trigger +# on clog (at extruder) or tangle (at MMU) conditions. +# +# Autotune: An autotuning option can be enabled for dynamic tuning (and persistence) of +# calibrated MMU gear rotation_distance. +# +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# + +from __future__ import annotations +from dataclasses import dataclass +from typing import Optional, Literal, Dict, Any +from collections import deque + +import math +import io, json # for debug log + + +# Sync-Feedback sensor type: +SensorType = Literal["P", "D", "CO", "TO"] + +# ----------------------------------------------------------------------------- +# SyncControllerConfig reference +# ----------------------------------------------------------------------------- +# +# Mechanics +# - buffer_range_mm (mm) Usable sensor travel that maps linearly to x ∈ [-1,+1]. +# All control logic is normalized by this. Increase if your +# sensor saturates too easily; decrease for a “tighter” x scale. +# - buffer_max_range_mm (mm) Physical clamp of the spring/buffer travel (|x| clipping). +# Must be ≥ buffer_range_mm. Used by the simulator and for +# visualization/safety margins. +# - sensor_type "P" => proportional z ∈ [-1, +1]; uses EKF + PD + KD +# "D" => discrete dual-switch z ∈ {-1,0,+1}; twolevel only +# "CO" => compression-only switch z ∈ {0,+1}; twolevel only +# "TO" => tension_only switch z ∈ {-1,0}; twolevel only +# +# Core lag tuning (readiness r) +# - sensor_lag_mm (mm) Motion required before treating sensor changes as “fresh info”. +# r ramps from 0→1 across this distance (gates smoothing/rates). +# 0 disables gating (r=1 always). +# - info_delta_a For Type-P only: minimum |Δz| to count as “new info”. +# Helps suppress tiny noise from constantly resetting the lag meter. +# +# Gains (PD on x with deadband) +# - kp Proportional gain on x (after deadband). Larger => stronger pull +# toward neutral; too high can oscillate near zero. +# - kd Derivative on x (Type-P only; requires dt>0). Dampens fast x +# changes. Set 0 to disable if your signal is noisy. +# - ctrl_deadband No-action band around x=0. Prevents over-correcting tiny errors. +# +# EKF noises +# - q_x Process noise on x. Larger trusts the model less => faster tracking, +# but noisier estimates. +# - q_c Process noise on c (calibration). Larger lets c drift/learn faster. +# - r_type Measurement noise for Type-P. Larger trusts the sensor less. +# +# Calibration bounds +# - c_min, c_max Hard clamps for c (effective compliance/throughput factor). +# Keep wide enough to cover materials but not so wide that c runs away. +# +# FlowGuard (distance-based) +# - flowguard_extreme_threshold Threshold in x or z treated as “pegged” (≈ jam/tangle). +# Used for detection, readiness floor, and relief logic. +# - flowguard_relief_mm (mm) Required accumulated “relief” motion to prove we tried to +# correct an extreme. If None, defaults to buffer_max_range_mm. USER EXPOSED +# +# Rotation distance +# - rd_start (mm) Default/persisted baseline RD. Used as mirror reference for mapping +# - rd_min_max_speed_multiplier Allowed RD bounds based on % speed +# - rd_twolevel_speed_multiplier Min/Max RD based on % speed for twolevel operation USER EXPOSED +# - rd_twolevel_boost_multiplier Extra boost speed USER EXPOSED +# +# Distance-based smoothing & slew +# - rd_filter_len_mm (mm) Exponential smoothing length vs extruder motion. +# Alpha = 1 - exp(-|Δmm| / L). Larger L = slower RD changes. +# - rd_rate_per_mm Hard rate limit on |ΔRD| per mm of motion (scaled by readiness r). +# None disables. Works together with rd_filter_len_mm; the tighter one wins. +# +# Extreme behavior +# - readiness_extreme_floor Minimum readiness r when the sensor/estimate is pegged. Ensures +# RD can change quickly enough under clear faults. +# - rate_extreme_multiplier Multiplier on rd_rate_per_mm when pegged (speed up corrections). +# - snap_at_extremes If True, apply relief-biased snap when pegged +# per update) to move away from the peg. +# - extreme_relief_frac Fraction of |d_ext| used to compute a relief RD step each update +# when snap_at_extremes is active. Typical 0.15–0.35. +# +# Autotune +# EKF logic: +# - autotune_stable_x_thresh Consider “near neutral” if |x| ≤ this. +# Determines when we accumulate samples for autotune. +# - autotune_stable_time_s Minimum time spent near neutral before we consider autotuning. +# - autotune_basis "time" | "motion" | "either" | "both" — which tests must pass. +# - autotune_motion_mm Motion near neutral required if basis uses motion. +# considered too small to avoid recommending trivial changes. +# - autotune_var_rel_frac Max allowed std(speed) near neutral required for autotune to propose an update +# - autotune_var_len_mm Distance over which to estimate RD mean/variance during the near-neutral “stable” window. +# Twolevel logic: +# - autotune_significance_z Z-score tests for twolevel estimator (0 disables, 2≈95% confidence). +# Shared logic: +# - autotune_cooldown_s/mm Minimum time/motion since the last autotune before another suggestion. +# +# Tuning tips: +# - If RD reacts too sluggishly in normal operation, decrease rd_filter_len_mm and/or increase +# rd_rate_per_mm (watch stability near neutral). +# - If you see chatter near x=0, reduce kp and/or kd, or increase r_type. +# - If FlowGuard trips too early, raise flowguard_relief_mm. +# - If autotune fires too often, increase the cooldowns; if it +# never fires, reduce autotune_stable_time_s and/or autotune_motion_mm. + +@dataclass +class SyncControllerConfig: + # Logging + log_sync: bool = False # whether to create log of every tick for debugging purposes + log_file: str = "/tmp/sync.jsonl" # debugging/plotting json log + + # Mechanics + buffer_range_mm: float = 8.0 # sensor usable travel (maps to normalized [-1,+1]) + buffer_max_range_mm: float = 14.0 # physical max travel (spring clamp) ≥ buffer_range_mm + sensor_type: SensorType = "D" + + # Core lag tuning (readiness r) + sensor_lag_mm: float = 0.0 # expected motion to see new info; 0 => no lag gating (r=1) + info_delta_a: float = 0.08 # Type-P: min sensor delta to count as "new info" + + # Gains (PD on x with deadband) + kp: float = 0.5 + kd: float = 0.4 # derivative term (used for Type-P) + ctrl_deadband: float = 0.1 # neutral deadband for PD around x=0 + + # EKF noises + q_x: float = 1e-3 + q_c: float = 5e-5 + r_type: float = 2.5e-2 + + # Calibration bounds + c_min: float = 0.25 + c_max: float = 4.0 + + # FlowGuard (distance-based) + flowguard_extreme_threshold: float = 0.9 + flowguard_relief_mm: Optional[float] = None + + # Rotation distance + rd_start: float = 20.0 # initial baseline (previous calibrated value) + rd_min_max_speed_multiplier: float = 0.25 # ±25% speed + rd_twolevel_speed_multiplier: float = 0.05 # ±5% speed + rd_twolevel_boost_multiplier: float = 0.05 # ±5% extra boost speed + + # Distance-based smoothing & slew + rd_filter_len_mm: float = 25.0 # exp smoothing length (mm of extruder motion for ~63% step @ r=1) + rd_rate_per_mm: Optional[float] = 0.10 # per-mm hard rate limit on ΔRD (scaled by readiness) + + # Extreme behavior control + readiness_extreme_floor: float = 0.7 # when pegged, raise r to at least this + rate_extreme_multiplier: float = 2.0 # multiply rate cap when pegged + snap_at_extremes: bool = True # enable relief-biased snap when pegged + extreme_relief_frac: float = 0.25 # fraction of |d_ext| of guaranteed relief per update + + # EKF autotune logic tests + autotune_stable_x_thresh: float = 0.12 + autotune_stable_time_s: float = 4.0 + autotune_basis: str = "both" + autotune_motion_mm: Optional[float] = None + autotune_var_rel_frac: float = 0.004 # allow ≈0.4% relative speed std + autotune_var_len_mm:float = None + + # Twolevel logic tests + autotune_significance_z: float = 1.0 # z-score (twolevel confidence) threshold to accept new RD (0 disables, 1≈68%, 2≈96%) + + # Shared tests + autotune_cooldown_s: float = 10.0 + autotune_cooldown_mm: float = 100.0 + autotune_min_save_frac: float = 0.001 # Only consider saving if > ≈0.1% speed change from last persisted value + + # Certainty tracking of rd recommendations + autotune_cert_window: int = 8 # fifo length of rd certainty scores + autotune_cert_tau_rel: float = 0.01 # target relative SE (e.g. 1%) + autotune_cert_n0: float = 3.0 # prior sample penalty + autotune_cert_hysteresis: float = 0.001 # min score improvement to accept + + os_min_flip_mm: float = 0.0 # minimum motion between flips (anti-chatter) + + # Optional two-level for P type sensors + use_twolevel_for_type_p: Optional[bool] = None # True/False to force option for type-P sensors + p_twolevel_threshold: float = 0.80 # P extreme if z>=+thr or z<=-thr + p_twolevel_hysteresis: float = 0.2 # shrink threshold by this when exiting a twolevel extreme + + def __post_init__(self): + if self.buffer_range_mm <= 0: + raise ValueError("buffer_range_mm must be > 0") + if self.buffer_max_range_mm <= 0: + raise ValueError("buffer_max_range_mm must be > 0") + if self.buffer_max_range_mm < self.buffer_range_mm: + raise ValueError("buffer_max_range_mm must be ≥ buffer_range_mm") + + # Autotune window defaults + if self.autotune_motion_mm is None: + self.autotune_motion_mm = 3.0 * self.rd_filter_len_mm + if self.autotune_var_len_mm is None: + self.autotune_var_len_mm = 1.8 * self.rd_filter_len_mm + + # FlowGuard relief threshold (how much "counter-effort" must be proven) + if self.flowguard_relief_mm is None: + mult = 0.3 if self.sensor_type in ['P'] else 0.7 + self.flowguard_relief_mm = max(mult * self.buffer_range_mm, self.buffer_max_range_mm) + + +# ------------------------------- EKF State ------------------------------ + +@dataclass +class EKFState: + """ + EKF state for [x, c] with covariance. + """ + x: float = 0.0 + c: float = 1.0 + P11: float = 0.5 + P12: float = 0.0 + P22: float = 0.2 + x_prev: float = 0.0 + + +# ------------------------------ Autotune Engine ------------------------- + +class _AutotuneEngine: + """ + Helper object that owns *all* autotune bookkeeping and decisions + """ + + def __init__(self, ctrl): + self.ctrl = ctrl + + # Core counters and state + self._total_motion_mm = 0.0 + self._total_time_s = 0.0 + self._paused = False + + # PD window stats + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + self._rd_ema_mean = None + self._rd_ema_var = 0.0 + + # Autotune anchors & cooldown trackers + self._autotune_last_time_s = -1e12 + self._autotune_last_motion_mm = -1e12 + self._autotune_baseline = self.ctrl.rd_ref # Persisted rd setting + self._autotune_current = self.ctrl.rd_ref # Current recommendation + self._autotune_min_cert_score = 0.5 # Don't recommend persist if less than this score + + # Suggestion tracking + self._rd_cert_fifo = deque(maxlen=int(max(1, ctrl.cfg.autotune_cert_window))) + self._rd_cert_last_score = -1.0 + + # Two-level estimator buckets & evidence + self._tl_flips = 0 + self._tl_updates_since_flip = 0 + + # Segment/cycle tracking for two-level duty estimator + self._tl_seg_level = None # "low" / "high" + self._tl_seg_mm = 0.0 # +ve (prevailing extrude) or -ve (prevailing retract) + self._tl_seg_mm_extreme = 0.0 # +ve (prevailing extrude) or -ve (prevailing retract) + self._tl_samples_low = [] # FIFO of extruder distances traveled while in "low" state + self._tl_samples_high = [] # FIFO of extruder distances traveled while in "high" state + self._tl_last_unpaired_low = None + self._tl_last_unpaired_high = None + self._tl_cycles = [] # List of (low_mm, high_mm) + self._tl_seg_window = 6 # Moving window per level + self._tl_cycle_window = 4 # Moving window of paired cycles + if ctrl.cfg.sensor_type in ['CO', 'TO']: + self._tl_min_cycles = 4 # Required minimum number of samples + else: + self._tl_min_cycles = 2 # Less because using full "buffer_range" + + # -------------------------------- API ----------------------------------- + + def restart(self, rd_init, reset_totals=True, reset_cooldown=True, reset_confidence=True): + """ + Rebase all autotune anchors/windows on a fresh baseline for new starting rd value + - Cooldown timers are either reset-to-now (default) or to a large negative origin + - Total counts are optionally reset + """ + self._autotune_current = rd_init + self._paused = False + + # Reset or preserve cooldown origins + if reset_cooldown: + self._autotune_last_time_s = self._total_time_s + self._autotune_last_motion_mm = self._total_motion_mm + else: + self._autotune_last_time_s = -1e12 + self._autotune_last_motion_mm = -1e12 + + # Suggestion tracking + if reset_confidence: + self._rd_cert_fifo = deque(maxlen=int(max(1, self.ctrl.cfg.autotune_cert_window))) + self._rd_cert_last_score = -1.0 + + # Reset or preserve core counters + if reset_totals: + self._total_motion_mm = 0.0 + self._total_time_s = 0.0 + + # PD window stats + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + + # Two-level estimator buckets & evidence + self._tl_flips = 0 + self._tl_updates_since_flip = 0 + + # Clear segment/cycle tracking for two-level duty estimator + self._tl_seg_level = None + self._tl_seg_mm = 0.0 + self._tl_seg_mm_extreme = 0.0 + self._tl_samples_low = [] + self._tl_samples_high = [] + self._tl_last_unpaired_low = None + self._tl_last_unpaired_high = None + self._tl_cycles = [] + + + def pause(self): + """ + Called to pause autotune generally because we received a large retract or we know + we are going to do an extended retract (tuning only work reliably in a extruder direction) + or we are performing movement that is known to cause underextrusion (like blobifer purge). + """ + if not self._paused: + self._paused = True + + + def resume(self): + """ + Resume autotune monitoring. We have to perform a soft reset. + """ + if self._paused: + self.restart(self._autotune_current, reset_totals=False, reset_cooldown=False, reset_confidence=False) + + + def note_twolevel_tick(self, os_level, flipped, d_ext, is_extreme=False): + """ + Called once per update_autotune() in two-level branch to keep buckets/evidence up-to-date. + """ + if self._paused: return + + cfg = self.ctrl.cfg + + # Flip handling + if flipped: + self._tl_updates_since_flip = 0 + self._tl_flips += 1 + else: + self._tl_updates_since_flip += 1 + + # Only accumulate segments after the first flip to remove startup conditions + if self._tl_flips < 1: + return + + # Accumulate current segment distance + self._tl_seg_mm += d_ext + if is_extreme: + self._tl_seg_mm_extreme += d_ext + + # On flip: close previous segment (if started) and store sample + if flipped: + seg_level = self._tl_seg_level + seg_mm = abs(self._tl_seg_mm) + + if seg_level == "low": + self._tl_samples_low.append(seg_mm) + if len(self._tl_samples_low) > self._tl_seg_window: + self._tl_samples_low.pop(0) + # Pair with existing high if available + if self._tl_last_unpaired_high is not None: + self._tl_cycles.append((seg_mm, self._tl_last_unpaired_high)) + if len(self._tl_cycles) > self._tl_cycle_window: + self._tl_cycles.pop(0) + self._tl_last_unpaired_high = None + else: + self._tl_last_unpaired_low = seg_mm + + elif seg_level == "high": + self._tl_samples_high.append(seg_mm) + if len(self._tl_samples_high) > self._tl_seg_window: + self._tl_samples_high.pop(0) + # Pair with existing low if available + if self._tl_last_unpaired_low is not None: + self._tl_cycles.append((self._tl_last_unpaired_low, seg_mm)) + if len(self._tl_cycles) > self._tl_cycle_window: + self._tl_cycles.pop(0) + self._tl_last_unpaired_low = None + else: + self._tl_last_unpaired_high = seg_mm + + # Start new segment for the new level + self._tl_seg_level = os_level + self._tl_seg_mm = 0.0 + self._tl_seg_mm_extreme = 0.0 + + + def update_autotune(self, d_ext, dt_s, report_trivial=False): + """ + On sensor update, recommend rd update based on mode: + - If two-level mode is active (CO/TO/D, or P with use_twolevel_for_type_p=True), + only query the two-level estimator. + - Otherwise, only query the PD near-neutral window. + If rd is recommended, run through shared statistical tests + """ + status = {"rd": None, "note": "", "save": False} + + if self._paused: + status["note"] = "Autotune: Paused" + return status + + cfg = self.ctrl.cfg + + # Track time/movement + self._total_time_s += max(0.0, float(dt_s)) + self._total_motion_mm += abs(float(d_ext)) + travel = "@{:.0f}s/{:.0f}mm".format(self._total_time_s, self._total_motion_mm) + + # Cooldown - sufficient motion/time since last save + since_mm = self._total_motion_mm - self._autotune_last_motion_mm + since_s = self._total_time_s - self._autotune_last_time_s + req_mm = cfg.autotune_cooldown_mm + req_s = cfg.autotune_cooldown_s + if since_mm < req_mm or since_s < req_s: + return status + + if self.ctrl.twolevel_active: + rec_rd, note = self._recommend_rd_from_twolevel() + else: + rec_rd, note = self._recommend_rd_from_ekf_path(d_ext, dt_s) + + + # No recommendation but optional reject note + if rec_rd is None: + status["note"] = "Autotune: {} {}".format(travel, note) if note else "" + return status + + # Perform final shared checks on recommendation... + + if not (self.ctrl.rd_low <= rec_rd <= self.ctrl.rd_high): + status["note"] = "Autotune: {} Rejected rd {:.4f} because out of bounds!".format(travel, rec_rd) + return status + + # This makes is progressively harder to accept autotune + rec_rd, _note = self._autotune_confident(rec_rd) + if rec_rd is None: + status["note"] = "Autotune: {} {}".format(travel, _note) if _note else "" + return status + + # Do nothing on truly trivial changes + if not report_trivial and math.isclose(rec_rd, self._autotune_current, abs_tol=1e-3): + status["note"] = "Autotune: {} Rejected rd {:.4f} because too trivial a delta".format(travel, rec_rd) + return status + + # We have new tuned rd value... + self._autotune_current = rec_rd + status["rd"] = rec_rd + status["note"] = "Autotune: {} {} and {}".format(travel, note, _note) + + # Should we recommend saving as new default reference? + if self._rd_cert_last_score >= self._autotune_min_cert_score: + frac = self._frac_speed_delta(rec_rd, self._autotune_baseline) + min_frac = cfg.autotune_min_save_frac + if frac >= min_frac: + self._autotune_baseline = rec_rd + status["save"] = True + + self.restart(rec_rd, reset_totals=False, reset_cooldown=False, reset_confidence=False) + return status + + + def twolevel_phase(self, exclude_extreme=False): + """ + Return (level, phase) for current two-level segment where + phase is measured between extremes (extreme motion removed). + Return None if we don't have enough evidence yet. + phase : progress in [0,1] within current segment (distance-based) + level : "low" | "high" (segment we're currently in) + extruding : true if extruding + """ + level = self._tl_seg_level + if level is None: + return None + + samples = self._tl_samples_high if level == "high" else self._tl_samples_low + if not samples: + return None + + mean_len = sum(samples) / float(len(samples)) + travel = abs(self._tl_seg_mm) + if exclude_extreme: + travel -= abs(self._tl_seg_mm_extreme) + mean_len -= abs(self._tl_seg_mm_extreme) + phase = max(0.0, min(1.0, travel / max(1e-6, mean_len))) + + return phase, level, self._tl_seg_mm > 0 + + + def get_rec_rd(self): + """ + Return the current recommended RD + """ + return self._autotune_current + + def get_tuned_rd(self): + """ + Return the last tuned RD. Initially this is the starting value + """ + return self._autotune_baseline + + + # ---------------------------- Internal Impl ----------------------------- + + + def _recommend_rd_from_ekf_path(self, d_ext, dt_s): + """ + Autotune baseline RD using EKF path statistics gathered near neutral. + Returns: Tuple (rec_rd|None, note|None) + """ + cfg = self.ctrl.cfg + + # Stability tests near neutral + stability_test = abs(self.ctrl.state.x) < cfg.autotune_stable_x_thresh + + # Accrue stable time/motion + move = abs(d_ext) + if stability_test: + self._stable_time += dt_s + self._stable_motion_mm += move + + # if move == 0.0: leave EMA unchanged this tick + if move > 0.0: + L = max(1e-9, cfg.autotune_var_len_mm) + alpha = 1.0 - math.exp(-move / L) + + # --- EMA in SPEED space: v = 1 / rd_current --- + rd_curr = max(1e-9, float(self.ctrl.rd_current)) + v = 1.0 / rd_curr + + if self._rd_ema_mean is None: + # Seed on first accepted sample (now in speed space) + self._rd_ema_mean = v + self._rd_ema_var = 0.0 + else: + # EWMA mean + West's EW variance, in speed space + m_prev = self._rd_ema_mean + d = v - m_prev + m_new = m_prev + alpha * d + v_new = (1.0 - alpha) * (self._rd_ema_var + alpha * d * d) + + self._rd_ema_mean = m_new + self._rd_ema_var = max(0.0, v_new) + + else: + # Leaving stable test -> drop stats so we don't carry junk + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + self._rd_ema_mean = None + self._rd_ema_var = 0.0 + + if self._rd_ema_mean is None: + return None, None + + time_ok = (self._stable_time >= cfg.autotune_stable_time_s) + motion_ok = (self._stable_motion_mm >= (cfg.autotune_motion_mm or 0.0)) + if cfg.autotune_basis == "time": + ready = time_ok + elif cfg.autotune_basis == "motion": + ready = motion_ok + elif cfg.autotune_basis == "either": + ready = time_ok or motion_ok + else: + ready = time_ok and motion_ok + if not ready: + return None, None + + # --- Interpret EMA as SPEED stats, then map back to RD --- + mean_v = max(self._rd_ema_mean, 1e-12) # mean speed + var_v = max(0.0, self._rd_ema_var) # variance of speed + + # Speed-relative variance test: std(speed)/mean(speed) ≤ f + f = cfg.autotune_var_rel_frac + std_v = math.sqrt(var_v) + rel_std_v = std_v / mean_v if mean_v > 0 else float("inf") + if rel_std_v > f: + # Convert mean_v back to rd for reporting + mean_rd_for_note = 1.0 / mean_v + note = f"Rejected rd {mean_rd_for_note:.4f} due to speed-relative variance {rel_std_v:.4f} > {f:.4f}" + return None, note + + # Potential new candidate: + # Convert mean speed back to an rd estimate + mean_rd = 1.0 / mean_v + note = f"EKF logic suggests rd≈{mean_rd:.4f} after {self._stable_time:.1f}s/{self._stable_motion_mm:.1f}mm near neutral" + return mean_rd, note + + + def _recommend_rd_from_twolevel(self): + """ + Minimal statistical baseline update for two-level mode for CO/TO sensor types or + optionally P/D types if configured in twolevel mode. + Returns: Tuple (rec_rd|None, note|None) + """ + cfg = self.ctrl.cfg + + # Only evaluate *on flips* (state changes) + if self._tl_updates_since_flip != 0: + return None, None + + # Require min segment samples per state and min total cycles + n_low = len(self._tl_samples_low) + n_high = len(self._tl_samples_high) + n_cycles = len(self._tl_cycles) + if n_low < self._tl_min_cycles or n_high < self._tl_min_cycles or n_cycles < self._tl_min_cycles: + return None, None + + # Compute per-cycle fractions for variance (fh_list) and ratio-of-sums for mean duty + fh_list = [] + dl_sum = 0.0 + dh_sum = 0.0 + for (dl, dh) in self._tl_cycles[-self._tl_cycle_window:]: + tot = max(1e-12, dl + dh) + fh_list.append(dh / tot) # per-cycle fraction (for variance) + dl_sum += dl + dh_sum += dh + + if not fh_list: + return None, None + + # Duty (mean) via ratio-of-sums + tot_sum = max(1e-12, dl_sum + dh_sum) + fh_mean = dh_sum / tot_sum + + # Duty-weighted *speed* estimate, then map back to RD to remove RD-space bias + v_low = 1.0 / max(1e-9, self.ctrl.rd_low) + v_high = 1.0 / max(1e-9, self.ctrl.rd_high) + v_est = (1.0 - fh_mean) * v_low + fh_mean * v_high + rd_est = 1.0 / max(1e-9, v_est) + + # rd_est significance test via z-score from variability of fh across cycles to see if it + # statistically distinguishable from the baseline given the observed variability + z = None + if cfg.autotune_significance_z > 0.0 and len(fh_list) >= 2: + # Sample std of fh across cycles + mu = sum(fh_list) / float(len(fh_list)) + var_f = sum((f - mu) ** 2 for f in fh_list) / float(max(1, len(fh_list) - 1)) + std_f = math.sqrt(var_f if var_f > 0.0 else 0.0) + se_f = (std_f / math.sqrt(len(fh_list))) if len(fh_list) > 0 else float("inf") + + # Propagate fh uncertainty through rd = 1 / ((1-f)/rd_low + f/rd_high) + # drd/df = rd^2 * (1/rd_low - 1/rd_high) = rd^2 * (v_low - v_high) + sensitivity = (rd_est ** 2) * abs(v_low - v_high) + se_rd = sensitivity * se_f # Std error in rd + + if se_rd >= 1e-9: + z = abs(rd_est - self._autotune_current) / se_rd + if z < float(cfg.autotune_significance_z): + note = ("Rejected rd {:.4f} because z-score {:.2f} not significant (<{:.2f})").format(rd_est, z, cfg.autotune_significance_z) + return None, note + # else: se_rd ~ 0 => treat as pass (perfect/no-variance case) + + # Potential new candidate + score = ("%.2f" % z) if z is not None else "perfect" + note = ("Two-level logic suggests rd≈{:.4f} (duty {:.2f} over {} cycles, z-score={})").format(rd_est, fh_mean, len(fh_list), score) + return rd_est, note + + + def _certainty_score(self, samples, tau_rel=0.01, n0=3.0, eps=1e-12): + """ + Certainty in [0,1]. Higher = more certain. + - tau_rel: target relative SE; smaller => stricter (e.g., 0.01 = 1%) + - n0: prior sample penalty; larger => more skepticism with small n + Returns: (score, mean, se, n) + """ + vals = [float(v) for v in samples if v is not None] + n = len(vals) + if n == 0: + return 0.0, None, None, 0 + + m = sum(vals) / float(n) + + if n >= 2: + mean_sq = sum(v*v for v in vals) / float(n) + var = max(0.0, mean_sq - m*m) * n / float(max(1, n - 1)) # unbiased + s = math.sqrt(var) + else: + s = 0.0 + + se = s / math.sqrt(n) if n > 0 else float('inf') + rel_se = se / max(abs(m), eps) if m != 0.0 else float('inf') + + # Precision shrinks as relative SE grows; bounded (0,1] + prec = 1.0 / (1.0 + (rel_se / max(tau_rel, eps))) + + # Sample-size prior; bounded [0,1) + size = n / (n + float(n0)) + + # Enforce "n<2 -> effectively no certainty" + if n < 2: + prec = 0.0 + + score = prec * size + return score, m, se, n + + def _frac_speed_delta(self, rd_new, rd_ref): + # |v(new)-v(ref)| / v(ref) with v = 1/rd => |rd_ref/rd_new - 1| + return abs((rd_ref / max(1e-9, rd_new)) - 1.0) + + def _autotune_confident(self, rec_rd): + """ + All speed-relative: + - min fractional speed delta vs last saved value + Returns: tuple(True|False, reason) + """ + cfg = self.ctrl.cfg + + # Require ever increasing certainty score (std error + n) + tau_rel = cfg.autotune_cert_tau_rel + n0 = cfg.autotune_cert_n0 + hyster = cfg.autotune_cert_hysteresis + + # Push proposal and score the window + self._rd_cert_fifo.append(rec_rd) + score, mean, se, n = self._certainty_score(self._rd_cert_fifo, tau_rel=tau_rel, n0=n0) + prev = self._rd_cert_last_score + threshold = 0 if prev == 0 else max(prev + hyster, 0) + improved = score > threshold + + if not improved: + if prev < 0: + self._rd_cert_last_score = 0. + note = "Rejected new rd {:.4f} due to certainty score of zero (n={})".format(rec_rd, n) + else: + note = "Rejected new rd {:.4f} due to certainty score {:.3f} ≤ prev {:.3f} (n={})".format(rec_rd, score, prev, n) + return None, note + + self._rd_cert_last_score = score + note = "with certainty score of {:.3f} (prev {:.3f}), n={}, mean {:.4f}, SE {:.4f}".format( + score, prev, n, mean if mean is not None else float('nan'), se if se is not None else float('nan')) + return mean, note + +# ----------------------------- Flowguard Engine ------------------------- + +class _FlowguardEngine: + """ + Encapsulates FlowGuard state and logic. Determines based on total filament movement + and amount of rd correction applied if a clog or tangle is likely to have occurred. + A reason string explains the reason for the trigger. + A single update_flowguard() entry point to be called on each tick. + """ + + def __init__(self, ctrl): + self.ctrl = ctrl + self.reset() + + # -------------------------------- API ----------------------------------- + + def reset(self): + # Accumulators + self._comp_motion_mm = 0.0 + self._tens_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + self._relief_tens_mm = 0.0 + + # Condition monitoring + self._trigger = "" + self._reason = "" + self._level = 0.0 + self._max_clog = 0.0 + self._max_tangle = 0.0 + self._motion_headroom = 0.0 # Debugging + self._relief_headroom = 0.0 # Debugging + + # FlowGuard arming test + self._armed = False # Disarmed until a state change while moving + self._arm_motion_mm = 0.0 # Motion since last (or initial) state sample + self._arm_last_state = None + + def update_flowguard(self, d_ext, sensor_reading): + """ + Distance-based FlowGuard with symmetric handling for one-sided switches. + + - For P/D sensors: + Uses controller._extreme_flags() on the sensor reading. + - For CO/TO sensors: + Uses the sensor directly for the *seen* side, and infer the unseen side + CO (compression-only): unseen = TENSION; relief effort is COMPRESSION (delta_rel > 0) + TO (tension-only) : unseen = COMPRESSION; relief effort is TENSION (delta_rel < 0) + Returns: Status Dict {"trigger", "reason", ...} + """ + cfg = self.ctrl.cfg + effort = self._relief_effort(d_ext) # +ve => compression effort, -ve => tension effort + + # Get the current sensor state for FlowGuard purposes (CO/TO are always at extreme) + state_now = self._flowguard_polarity(sensor_reading) + comp_ext, tens_ext = (state_now == 1, state_now == -1) + + # Arming logic to prevent false triggers on startup if thresholds are tight + self._arm_motion_mm += d_ext + state_now = self.ctrl._extreme_polarity(sensor_reading) + if self._arm_last_state is None: + self._arm_last_state = state_now + + self._relief_headroom = cfg.flowguard_relief_mm + + if not self._armed: + # Arm when we've moved and observed any change in coarse state + changed = (state_now != self._arm_last_state) + if abs(self._arm_motion_mm) > 0.0 and changed: + self._armed = True + else: + return self.status() + self._arm_last_state = state_now + + if comp_ext: # Extreme Compression + self._comp_motion_mm += d_ext + + # Relief for compression is *tension* effort (delta_rel < 0) + if effort < 0: + self._relief_comp_mm += (-effort) + + comp_relief_trig = (abs(self._relief_comp_mm) >= cfg.flowguard_relief_mm) + self._relief_headroom -= self._relief_comp_mm + + if comp_relief_trig and not self._trigger: + self._trigger = "clog" + self._reason = "Compression stuck after %.2f mm motion and %.2f mm relief (triggering parameter: flowguard_max_relief)" % ( + self._comp_motion_mm, self._relief_comp_mm + ) + + # Maintain normalized [0..1] clog headroom marker + mcr = abs(self._relief_comp_mm / cfg.flowguard_relief_mm) + c_level = min(1.0, mcr) + self._level = c_level + if c_level > self._max_clog: + self._max_clog = c_level + + # Reset the tension side when compression extreme is active + self._tens_motion_mm = 0.0 + self._relief_tens_mm = 0.0 + + elif tens_ext: # Extreme Tension + self._tens_motion_mm += d_ext + + # Relief for tension is *compression* effort (delta_rel > 0) + if effort > 0: + self._relief_tens_mm += effort + + tens_relief_trig = (abs(self._relief_tens_mm) >= cfg.flowguard_relief_mm) + self._relief_headroom -= self._relief_tens_mm + + if tens_relief_trig and not self._trigger: + self._trigger = "tangle" + self._reason = "Tension stuck after %.2f mm motion and %.2f mm relief (triggering parameter: flowguard_max_relief)" % ( + self._tens_motion_mm, self._relief_tens_mm + ) + + # Maintain normalized [0..-1] tangle headroom marker + mtr = -abs(self._relief_tens_mm / cfg.flowguard_relief_mm) + t_level = max(-1.0, mtr) + self._level = t_level + if self._level < self._max_tangle: + self._max_tangle = t_level + + # Reset the compression side when tension extreme is active + self._comp_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + + else: # No extreme: reset both sides + self._comp_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + self._tens_motion_mm = 0.0 + self._relief_tens_mm = 0.0 + + return self.status() + + def status(self): + s = { + "active": self._armed, + "level": self._level, + "max_clog": self._max_clog, + "max_tangle": self._max_tangle, + "trigger": self._trigger, + "reason": self._reason, + } + + # When debug logging + if self.ctrl.cfg.log_sync: + s.update({ + "headroom": self._motion_headroom, + "relief_headroom": self._relief_headroom, + }) + + return s + + def _relief_effort(self, d_ext): + """ + Signed relief 'effort' this tick (mm-equivalent). + Positive => compression effort, negative => tension effort. + + Baseline is autotune._autotune_current, i.e. the *tuned* RD: + - In twolevel mode, this matches the rd_ref we recenter around. + - In EKF mode, this is the learned "true" RD, even if rd_ref remains the + originally persisted value. + """ + rd_ref = self.ctrl.autotune.get_rec_rd() # Starts at self.ctrl.rd_ref + rd_cur = self.ctrl.rd_current + if abs(rd_cur) < 1e-9: + return 0.0 + return d_ext * ((rd_ref / rd_cur) - 1.0) + + def _flowguard_polarity(self, sensor_reading): + """ + FlowGuard-only coarse polarity. + + For CO/TO, treat OPEN as an extreme on the unseen side so FlowGuard tracks immediately: + CO: z==1 -> +1 (compression), z==0 -> -1 (tension-as-open) + TO: z==-1 -> -1 (tension), z==0 -> +1 (compression-as-open) + + For P/D, defer to controller polarity. + """ + cfg = self.ctrl.cfg + if cfg.sensor_type == "CO": + return 1 if int(sensor_reading) == 1 else -1 + if cfg.sensor_type == "TO": + return -1 if int(sensor_reading) == -1 else 1 + return self.ctrl._extreme_polarity(sensor_reading) + + +# -------------------------- Controller Core ---------------------------- + +class SyncController: + """ + Movement-triggered filament tension controller. + + update(eventtime, extruder_delta_mm, sensor_reading): + - Propagates EKF with motion & measurement + - Computes desired effective gear motion to pull x→0 (PD with derivative if Type-P) + - Converts to RD target via configurable gear mapping (symmetric/asymmetric), then distance-smoothed + - Relief-biased snap when sensor pegged + - Neutral trim near zero + - FlowGuard detection + - Autotune of baseline RD (time/motion near neutral, or two-level duty estimator) + """ + + def __init__(self, cfg: SyncControllerConfig, c0=1.0, x0=None): + self.cfg = cfg + self._set_twolevel_active() + + self._tick = 0 + self._last_time_s = None + self._log_ready = False + + self.K = 2.0 / cfg.buffer_range_mm # mm => normalized delta in x + self.state = EKFState() + self.state.c = max(cfg.c_min, min(cfg.c_max, c0)) + if x0 is not None: + self.state.x = max(-1.0, min(1.0, x0)) + + rd_init = float(cfg.rd_start) + self.rd_current = rd_init # Current rd in effect + self.rd_ref = rd_init # Last "tuned" rd + + # Allows initial wider range of rd until first autotune candidate + self._twolevel_boost_active = True + + self._twolevel_hys_state = 0 # -1, 0, +1 (last hysteretic extreme for type-P) + + # Set absolute limits for rd range + self._set_min_max_rd(rd_init) + + # Readiness (lag-aware) + self._mm_since_info = 0.0 + self._last_info_z = None + + # UI visualization + self._vis_est = 0.0 + + # Two-level flip-flop state (reused for CO/TO and optional P/D) + self._os_target_level = "low" # "low" or "high" + self._os_since_flip_mm = 0.0 + + # FlowGuard engine (encapsulates non-shared FlowGuard state/logic) + self.flowguard = _FlowguardEngine(self) + + # Autotune helper (encapsulates all autotune state/logic) + self.autotune = _AutotuneEngine(self) + + + # ------------------------------------ PUBLIC API ------------------------------------ + + def reset(self, eventtime, rd_init, sensor_reading, log_file=None, hard_reset=True, simulation=False): + """ + Full controller reset for a gear motor swap or new cold start. + Seeds internal time to `t_s` and zeroes elapsed time. + """ + cfg = self.cfg + self._set_twolevel_active() + + self._log_ready = False + self._current_log_file = log_file or cfg.log_file + + # Rotation distance & baseline (always rebase) + self.rd_current = rd_init + self.rd_ref = rd_init + self._twolevel_boost_active = True + self._set_min_max_rd(rd_init) + + # Seed x_hat from sensor reading + if cfg.sensor_type == "P": + z = float(sensor_reading) + x0 = max(-1.0, min(1.0, z)) + self._twolevel_hys_state = int(math.copysign(1, x0)) if abs(x0) >= 1e-6 else 0 + + else: + z = int(sensor_reading) + z = 1 if z > 0 else (-1 if z < 0 else 0) + x0 = float(z) + + # EKF state & covariance + if cfg.sensor_type == "P" and not self.twolevel_active: + self.state.x = float(x0) + self.state.x_prev = self.state.x + self.state.c = 1.0 + self.state.P11 = 0.5 + self.state.P12 = 0.0 + self.state.P22 = 0.2 + + # Readiness (lag-aware) + self._mm_since_info = 0.0 + self._last_info_z = float(x0) if cfg.sensor_type == "P" else int(round(x0)) + + # Time (for update()) + self._tick = 0 + self._last_time_s = eventtime + + # For UI + self._vis_est = float(sensor_reading) + + # Two-level init for CO/TO + if self.cfg.sensor_type in ("CO", "TO"): + in_contact0 = self._onesided_contact(sensor_reading) + if self.cfg.sensor_type == "CO": + self._os_target_level = "high" if in_contact0 else "low" + else: + self._os_target_level = "low" if in_contact0 else "high" + self._os_since_flip_mm = 0.0 + + # Two-level init for P/D (optional) + if self.cfg.sensor_type in ("P", "D") and self.twolevel_active: + pol0 = self._extreme_polarity(sensor_reading) + if pol0 > 0: + self._os_target_level = "high" + elif pol0 < 0: + self._os_target_level = "low" + else: + self._os_target_level = "low" # neutral start; will flip on first extreme + self._os_since_flip_mm = 0.0 + + if hard_reset: + # Rebase autotune helper on the new start + self.autotune.restart(rd_init) + + # Reset FlowGuard engine state on controller reset + self.flowguard.reset() + + # Setup special json debug log + if self.cfg.log_sync: + self._init_log() + + return self.update(eventtime, 0.0, sensor_reading, simulation=simulation) + + + def update(self, eventtime, extruder_delta_mm, sensor_reading, simulation=False): + """ + Required absolute timestamp `t_s` (seconds). Internally computes dt from the + last call (or reset). The timestamp must be monotonic non-decreasing. + """ + cfg = self.cfg + + if self._last_time_s is None: + self._last_time_s = eventtime + + if extruder_delta_mm < 0: + self.autotune.pause() + else: + self.autotune.resume() + + # Compute dt and advance time cursors + dt_s = max(0.0, float(eventtime - self._last_time_s)) # Protect against non-monotonic clock + self._last_time_s = eventtime + d_ext = float(extruder_delta_mm) + + rd_prev = self.rd_current + rd_note = None + d_gear = self._gear_mm_from_rd(d_ext, rd_prev) + + if self.twolevel_active: + # ------------------- TWO-LEVEL BRANCH ------------------ + + # FlowGuard update + flowguard_out = self.flowguard.update_flowguard(d_ext, int(sensor_reading) if cfg.sensor_type in ("CO", "TO", "D") else sensor_reading) + + # Determine immediate RD target from two-level rules + prev_level = self._os_target_level # Capture before helper changes (detect flips) + rd_target = self._twolevel_rd_target(rd_prev, d_ext, sensor_reading) + flipped_this_tick = (self._os_target_level != prev_level) + + # Delegate two-level evidence collection to autotune helper + if cfg.sensor_type in ("CO", "TO"): + extreme_active = self._onesided_contact(sensor_reading) + else: + extreme_active = (self._extreme_polarity(sensor_reading) != 0) + self.autotune.note_twolevel_tick(self._os_target_level, flipped_this_tick, d_ext, extreme_active) + + else: + # -------------------- KALMAN BRANCH -------------------- + + self._ekf_predict(extruder_mm=d_ext, gear_mm=d_gear) + self._ekf_update(float(sensor_reading)) + + # FlowGuard update + flowguard_out = self.flowguard.update_flowguard(d_ext, sensor_reading) + + # Compute immediate RD target + desired_eff = self._desired_effective_gear_mm(d_ext, dt_s) # = c_hat * u_des + c_hat = max(cfg.c_min, min(cfg.c_max, self.state.c)) + u_des = desired_eff / c_hat + rd_target = self._rd_from_desired_gear_mm(d_ext, u_des) + if rd_target is None: + rd_target = rd_prev # no extruder motion; hold RD + + # Relief-biased snap at extremes (guaranteed relief per update) + comp_ext, tens_ext = self._extreme_flags(sensor_reading) + if cfg.snap_at_extremes and d_ext != 0.0 and (comp_ext or tens_ext): + zsign = 1 if comp_ext else -1 # +1 compression, -1 tension + relief_frac = max(0.05, min(0.60, float(cfg.extreme_relief_frac))) + rd_ref = self.rd_ref + + # Derived from: delta_rel = d_ext * (c_hat * rd_ref / rd - 1) + sgn = 1.0 if d_ext > 0 else -1.0 + denom = 1.0 - (sgn * zsign) * relief_frac + denom = max(0.05, denom) + rd_target = (c_hat * rd_ref) / denom + rd_note = "Relief-biased snap at extreme" + + # Smooth target + rd_clamped = self._clamp_to_envelope(rd_target) + rd_target = self._smooth_rd_by_distance(rd_prev, rd_clamped, d_ext, sensor_reading=sensor_reading) + + # ------------- SHARED -------------- + + # Now clamp and apply the newly decided RD for future motion + rd_applied = self._clamp_to_envelope(rd_target) + if not math.isclose(self.rd_current, rd_applied): + self.rd_current = rd_applied + + # Update UI helper + sensor_expected = self._expected_sensor_reading(sensor_reading) + + # Autotune decision + autotune_out = self.autotune.update_autotune(d_ext, dt_s, report_trivial=self._twolevel_boost_active) + auto_rd = autotune_out.get('rd') + if auto_rd is not None: + if self.twolevel_active: + # Only adjust RD reference point if in twolevel mode to "center switching" + self.rd_ref = auto_rd + + # Reset boost (twolevel rd high/low) after first autotune candidate + self._twolevel_boost_active = False + self._set_low_high_rd(auto_rd) + + if flowguard_out.get('trigger'): + self.autotune.restart(self.rd_ref) + + # Essential output + out = { + "output": { + "rd_prev": rd_prev, + "rd_current": self.rd_current, + "rd_tuned": self.autotune.get_tuned_rd(), # What autotune believes to be accurate + "sensor_ui": sensor_expected, + "flowguard": flowguard_out, # Keys: "trigger", "reason", "level", "max_clog", "max_tangle", "active" + "autotune": autotune_out, # Keys: "rd", "note", "save" + } + } + + # Additional debug/logging info + if cfg.log_sync or simulation: + out["output"].update({ + "rd_target": rd_target, # Unclamped target on which rd_current is based + "rd_ref": self.rd_ref, # What EKF or twolevel logic is using as baseline + "rd_note": rd_note, + "x_est": self.state.x, + "c_est": self.state.c + }) + out["input"] = { + "tick": self._tick, + "t_s": eventtime, + "dt_s": dt_s, + "d_mm": extruder_delta_mm, + "sensor": sensor_reading + } + + if cfg.log_sync: + self._append_log_entry(out) + + self.state.x_prev = self.state.x + self._tick += 1 + return out + + + def polarity(self, sensor_reading): + return self._extreme_polarity(sensor_reading) + + + def get_type_mode(self): + sensor_type = self.cfg.sensor_type + if sensor_type == 'P': + sensor_type += " (TwoLevel mode)" if self.twolevel_active else " (EKF mode)" + return sensor_type + + + def get_current_rd(self): + """ + Return the current RD in use + """ + return self.rd_current + + + # --------------------------------- Internal Impl ------------------------------------ + + def _set_twolevel_active(self): + """ + Twolevel mode is updated on each reset to allow responsive behavior to sensor disable + """ + self.twolevel_active = ( + self.cfg.sensor_type in ("CO", "TO", "D") + or (self.cfg.sensor_type == "P" and self.cfg.use_twolevel_for_type_p is True) + ) + + + def _set_min_max_rd(self, rd): + """ + Set absolute immutable min/max rd "speeds" + """ + f_minmax = max(0.0, min(0.99, self.cfg.rd_min_max_speed_multiplier)) + + self.rd_min = rd / (1.0 + f_minmax) + self.rd_max = rd / (1.0 - f_minmax) + self._set_low_high_rd(rd) # Also set current low/high in effect + + + def _set_low_high_rd(self, rd): + """ + Set high/low rd "speeds" + """ + f_minmax = max(0.0, min(0.99, self.cfg.rd_min_max_speed_multiplier)) + if self.twolevel_active: + f_norm = self.cfg.rd_twolevel_speed_multiplier + f_boost = self.cfg.rd_twolevel_boost_multiplier if self._twolevel_boost_active else 0.0 + f = max(0.0, min(f_minmax, (f_norm + f_boost))) + else: + f = f_minmax + + self.rd_low = rd / (1.0 + f) + self.rd_high = rd / (1.0 - f) + + + def _clamp_to_envelope(self, rd): + """ + Never allow rd outside of limits + """ + return max(self.rd_min, min(self.rd_max, rd)) + + # -------------- Mapping helpers ----------------- + + def _gear_mm_from_rd(self, d_ext, rd): + """ + Map RD -> effective gear motion for this update. + Asymmetric mapping: + forward (d_ext > 0): u = d_ext * (rd_ref / rd) + retract (d_ext < 0): u = d_ext * (rd / rd_ref) + """ + rd_ref = self.rd_ref + d_ext = float(d_ext) + if abs(d_ext) < 1e-12: + return 0.0 + + if d_ext > 0.0: + scale = rd_ref / max(1e-9, rd) + else: + scale = max(1e-9, rd) / rd_ref + + return d_ext * scale + + def _rd_from_desired_gear_mm(self, d_ext, u_des): + """ + Invert the asymmetric mapping to get the RD target from desired effective gear motion. + Enforces no in-step reversal: u_des * d_ext must be > 0. + """ + rd_ref = self.rd_ref + d_ext = float(d_ext) + if abs(d_ext) < 1e-12: + return None + + # Prevent reversal within an update + if u_des * d_ext <= 0.0: + return self.rd_high if d_ext > 0 else self.rd_low + + if d_ext > 0.0: + # u = d_ext * (rd_ref / rd) => rd = rd_ref * d_ext / u + denom = u_des if abs(u_des) > 1e-12 else (1e-12) + rd = rd_ref * d_ext / denom + else: + # u = d_ext * (rd / rd_ref) => rd = (u * rd_ref) / d_ext + # (note: d_ext < 0 so division preserves sign correctly) + rd = (u_des * rd_ref) / d_ext + + return rd + + # --------------------- EKF ---------------------- + + def _ekf_predict(self, extruder_mm, gear_mm): + """ + Predict with the RD actually used last update (rd_prev): + """ + s, cfg = self.state, self.cfg + x_pred = s.x + self.K * (s.c * gear_mm - extruder_mm) + c_pred = s.c + + F11 = 1.0 + F12 = self.K * gear_mm + F21 = 0.0 + F22 = 1.0 + + P11, P12, P22 = s.P11, s.P12, s.P22 + FP11 = F11*P11 + F12*P12 + FP12 = F11*P12 + F12*P22 + FP21 = F21*P11 + F22*P12 + FP22 = F21*P12 + F22*P22 + + s.P11 = FP11*F11 + FP12*F12 + cfg.q_x + s.P12 = FP11*F21 + FP12*F22 + s.P22 = FP21*F21 + FP22*F22 + cfg.q_c + + s.x = max(-1.25, min(1.25, x_pred)) # Soft clamp in estimate space + s.c = max(cfg.c_min, min(cfg.c_max, c_pred)) + + + def _ekf_update(self, z): + s, cfg = self.state, self.cfg + z = max(-1.0, min(1.0, float(z))) + R = cfg.r_type + y = z - s.x + S = s.P11 + R + if S <= 0: + return + Kx = s.P11 / S + Kc = s.P12 / S + s.x += Kx * y + s.c += Kc * y + s.c = max(cfg.c_min, min(cfg.c_max, s.c)) + s.P22 -= (s.P12 * Kc) + s.P12 *= (1 - Kx) + s.P11 *= (1 - Kx) + + + # ----------- Sensor reading helpers ---------- + + def _onesided_contact(self, sensor_reading): + """ + True if the one-sided sensor is in-contact (triggered) + """ + cfg = self.cfg + + if cfg.sensor_type == "CO": + return int(sensor_reading) == 1 + + if cfg.sensor_type == "TO": + return int(sensor_reading) == -1 + + return False + + + def _extreme_polarity(self, sensor_reading): + """ + Reduce sensor to a coarse (extreme) states + P : +1 if ≥ threshold, -1 if ≤ -threshold, else 0 + D : {-1,0,+1} as-is + CO: {0, +1} as-is + TO: {-1, 0} as-is + Normally this is the flowguard threshold but the P/D twolevel is + usually a slightly lesser test + """ + cfg = self.cfg + if cfg.sensor_type != "P": + return int(sensor_reading) + + z = float(sensor_reading) + if not self.twolevel_active: + thr = cfg.flowguard_extreme_threshold + return 1 if z >= thr else -1 if z <= -thr else 0 + + # Add hysteresis on twolevel extreme for type-P sensor + hi = abs(float(cfg.p_twolevel_threshold)) + lo = max(0.0, hi - cfg.p_twolevel_hysteresis) + s = self._twolevel_hys_state + + if s != 0: + if s * z <= lo: + s = 0 + else: + s = (z >= hi) - (z <= -hi) + + self._twolevel_hys_state = s + return s + + + def _is_extreme(self, sensor_reading): + """ + True if current reading is pegged per sensor type + """ + return self._extreme_polarity(sensor_reading) != 0 + + + def _extreme_flags(self, sensor_reading): + """ + Return (compression_extreme, tension_extreme) + """ + p = self._extreme_polarity(sensor_reading) + return (p == 1, p == -1) + + # -------------- Twolevel helpers ------------- + + def _twolevel_rd_target(self, rd_prev, d_ext, sensor_reading): + """ + CO/TO: Pure two-level control. + - CO: open -> rd_low (seek compression), contact -> rd_high (relieve) + - TO: open -> rd_high (seek tension), contact -> rd_low (relieve) + Uses a small hysteresis on motion (os_min_flip_mm) so we don't chatter. + Returns the desired RD target for this update (before smoothing/rate limiting). + + P/D: Two-level mode (optional via config). + - Flip only at extremes (neutral band does not change RD). + - Compression extreme -> rd_high; Tension extreme -> rd_low. + """ + cfg = self.cfg + self._os_since_flip_mm += d_ext + + if cfg.sensor_type in ("CO", "TO"): + # Desired level from current contact state + in_contact = self._onesided_contact(sensor_reading) + if cfg.sensor_type == "CO": + desired_level = "high" if in_contact else "low" + else: # "TO" + desired_level = "low" if in_contact else "high" + + else: + # Desired level from polarity + pol = self._extreme_polarity(sensor_reading) # {+1, -1, 0} + if pol == 0: + desired_level = self._os_target_level + else: + desired_level = "high" if (pol > 0) else "low" + + # Flip only if we've moved enough since the last flip + if desired_level != self._os_target_level and abs(self._os_since_flip_mm) >= cfg.os_min_flip_mm: + self._os_target_level = desired_level + self._os_since_flip_mm = 0.0 + + # Map level to RD + rd_target = self.rd_low if self._os_target_level == "low" else self.rd_high + + return rd_target + + # ----------------- EKF helpers --------------- + + def _desired_effective_gear_mm(self, d_ext, dt_s): + s, cfg = self.state, self.cfg + dead = max(0.0, cfg.ctrl_deadband) + x = s.x + x_ctrl = 0.0 if abs(x) < dead else (x - math.copysign(dead, x)) + + kd_eff = cfg.kd if dt_s > 0 else 0.0 + dx = (s.x - s.x_prev) / max(1e-9, dt_s) if kd_eff != 0.0 else 0.0 + + return d_ext - cfg.kp * x_ctrl - kd_eff * dx + + + def _smooth_rd_by_distance(self, rd_prev, rd_target, d_ext, sensor_reading=None): + """ + Glide the current RD towards target using a fixed rd_filter_len_mm motion length + respecting readiness factor and extreme limits. + """ + move = abs(float(d_ext)) + + # Exponential smoothing for soft glide from rd_prev toward rd_target. + # Bigger move or higher r => bigger step. + L = max(1e-9, self.cfg.rd_filter_len_mm) + alpha_base = 1.0 - math.exp(-move / L) + r = self._update_readiness_and_get_r(sensor_reading, move) if sensor_reading is not None else 1.0 + alpha = r * alpha_base + + rd_filtered = rd_prev + alpha * (rd_target - rd_prev) + + # Rate limit (with extreme multiplier) + is_extreme = self._is_extreme(sensor_reading) if sensor_reading is not None else False + if self.cfg.rd_rate_per_mm is not None and move > 0: + rate_mult = (self.cfg.rate_extreme_multiplier if is_extreme else 1.0) + max_step = abs(self.cfg.rd_rate_per_mm) * move * r * rate_mult + rd_delta = rd_filtered - rd_prev + if rd_delta > max_step: + rd_filtered = rd_prev + max_step + elif rd_delta < -max_step: + rd_filtered = rd_prev - max_step + + return rd_filtered + + + def _update_readiness_and_get_r(self, sensor_reading, move_abs_mm): + """ + Returns (lag-aware) readiness value for 0=not ready, to 1=ready now + The purpose is so that we don’t react fully until we’ve seen enough + motion or a meaningful sensor change. + P-EKF only + """ + cfg = self.cfg + if cfg.sensor_lag_mm <= 0: + r = 1.0 + else: + self._mm_since_info += move_abs_mm + z = float(sensor_reading) + if self._last_info_z is None or abs(z - self._last_info_z) >= cfg.info_delta_a: + self._last_info_z = z + self._mm_since_info = 0.0 + L = max(1e-6, cfg.sensor_lag_mm) + r = max(0.0, min(1.0, self._mm_since_info / L)) + + if self._is_extreme(sensor_reading): + r = max(r, cfg.readiness_extreme_floor) + return r + + + def _expected_sensor_reading(self, sensor_reading): + """ + UI helper for prediction of idealized sensor reading. + Returns a float in [-1, 1] depending on sensor type. + """ + cfg = self.cfg + + # Type P: always passthrough true sensor reading + if cfg.sensor_type == "P": + self._vis_est = sensor_reading + return self._vis_est + + # Snap to extremes for D/CO/TO when pegged: + if self._is_extreme(sensor_reading): + self._vis_est = float(self._extreme_polarity(sensor_reading)) + return self._vis_est + + # Get phase info + ph = self.autotune.twolevel_phase(exclude_extreme=cfg.sensor_type == "D") + + # Snap to extreme if no phase info available + if ph is None: + self._vis_est = float(self._extreme_polarity(sensor_reading)) + return self._vis_est + + phase, level, extruding = ph + + # Type CO/TO: Split phase to encompass rebound + if cfg.sensor_type in ['CO', 'TO']: + + def triangle_half(p, lo=0.3, hi=0.8): + base = (1.0 - 2.0*p) if p <= 0.5 else (2.0*p - 1.0) # in [0,1] + return lo + (hi - lo) * base + + if cfg.sensor_type == "CO": + self._vis_est = triangle_half(phase) + else: + self._vis_est = -triangle_half(phase) + + return self._vis_est + + # Type D: Adjust phase to exclude extreme portion + def triangle_full(p, lo=-0.9, hi=0.9): + return lo + (hi - lo) * p + + t = triangle_full(phase) + if level == "low": + x_pred = t if extruding else -t # Ramping up if extruding + else: + x_pred = -t if extruding else t # Ramping down if extruding + self._vis_est = x_pred + return self._vis_est + + + # -------------- Logging helpers -------------- + + def _init_log(self, log_file=None): + """ + (Re)create the log file and write a single header entry. + Clears any existing file. + """ + header = { + "header": { + "rd_start": self.cfg.rd_start, + "sensor_type": self.cfg.sensor_type, + "twolevel_active": self.twolevel_active, + "buffer_range_mm": self.cfg.buffer_range_mm, + "buffer_max_range_mm": self.cfg.buffer_max_range_mm, + } + } + + with io.open(self._current_log_file, "a", encoding="utf-8") as f: + json.dump(header, f, ensure_ascii=False) + f.write("\n") + self._log_ready = True + + + def _append_log_entry(self, record): + """ + Append a single JSON object to the log as one line. + """ + if not self._log_ready: + return + + with io.open(self._current_log_file, "a", encoding="utf-8") as f: + json.dump(record, f, ensure_ascii=False) + f.write("\n") diff --git a/extras/mmu/mmu_sync_feedback_manager.py b/extras/mmu/mmu_sync_feedback_manager.py new file mode 100644 index 000000000000..c3238c8c124c --- /dev/null +++ b/extras/mmu/mmu_sync_feedback_manager.py @@ -0,0 +1,896 @@ +# -*- coding: utf-8 -*- +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager class to handle sync-feedback and adjustment of gear stepper rotation distance +# to keep MMU in sync with extruder as well as some filament tension routines. +# +# FlowGuard: It also implements protection for all modes/sensor types that will trigger +# on clog (at extruder) or tangle (at MMU) conditions. +# +# Autotune: An autotuning option can be enabled for dynamic tuning (and persistence) of +# calibrated MMU gear rotation_distance. +# +# Implements commands: +# MMU_SYNC_FEEDBACK +# MMU_FLOWGUARD +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, math, time, os + +# Happy Hare imports + +# MMU subcomponent clases +from .mmu_sync_controller import SyncControllerConfig, SyncController +from .mmu_extruder_monitor import ExtruderMonitor +from .mmu_shared import MmuError + +class MmuSyncFeedbackManager: + + SF_STATE_NEUTRAL = 0 + SF_STATE_COMPRESSION = 1 + SF_STATE_TENSION = -1 + + def __init__(self, mmu): + self.mmu = mmu + self.mmu.managers.append(self) + + self.estimated_state = float(self.SF_STATE_NEUTRAL) + self.active = False # Sync-feedback actively operating? + self.flowguard_active = False # FlowGuard armed? + self.ctrl = None + self.flow_rate = 100. # Estimated % flowrate (calc only for proportional sensors) + + # Process config + self.sync_feedback_enabled = self.mmu.config.getint('sync_feedback_enabled', 0, minval=0, maxval=1) + self.sync_feedback_buffer_range = self.mmu.config.getfloat('sync_feedback_buffer_range', 10., minval=0.) + self.sync_feedback_buffer_maxrange = self.mmu.config.getfloat('sync_feedback_buffer_maxrange', 10., minval=0.) + self.sync_feedback_speed_multiplier = self.mmu.config.getfloat('sync_feedback_speed_multiplier', 5, minval=1, maxval=50) + self.sync_feedback_boost_multiplier = self.mmu.config.getfloat('sync_feedback_boost_multiplier', 5, minval=1, maxval=50) + self.sync_feedback_extrude_threshold = self.mmu.config.getfloat('sync_feedback_extrude_threshold', 5, above=1.) + self.sync_feedback_debug_log = self.mmu.config.getint('sync_feedback_debug_log', 0) + self.sync_feedback_force_twolevel = self.mmu.config.getint('sync_feedback_force_twolevel', 0) # Not exposed + + # FlowGuard + self.flowguard_enabled = self.mmu.config.getint('flowguard_enabled', 1, minval=0, maxval=1) + self.flowguard_max_relief = self.mmu.config.getfloat('flowguard_max_relief', 8, above=1.) + self.flowguard_encoder_mode = self.mmu.config.getint('flowguard_encoder_mode', 2, minval=0, maxval=2) + self.flowguard_encoder_max_motion = self.mmu.config.getfloat('flowguard_encoder_max_motion', 20, above=0.) + + # Setup events for managing motor synchronization + self.mmu.printer.register_event_handler("mmu:synced", self._handle_mmu_synced) + self.mmu.printer.register_event_handler("mmu:unsynced", self._handle_mmu_unsynced) + self.mmu.printer.register_event_handler("mmu:sync_feedback", self._handle_sync_feedback) + + # Initial flowguard status + self.flowguard_status = {'trigger': '', 'reason': '', 'level': 0.0, 'max_clog': 0.0, 'max_tangle': 0.0, 'active': False, 'enabled': bool(self.flowguard_enabled)} + + # Register GCODE commands --------------------------------------------------------------------------- + + self.mmu.gcode.register_command('MMU_SYNC_FEEDBACK', self.cmd_MMU_SYNC_FEEDBACK, desc=self.cmd_MMU_SYNC_FEEDBACK_help) + self.mmu.gcode.register_command('MMU_FLOWGUARD', self.cmd_MMU_FLOWGUARD, desc=self.cmd_MMU_FLOWGUARD_help) + + self.extruder_monitor = ExtruderMonitor(mmu) + + + # + # Standard mmu manager hooks... + # + + def reinit(self): + self.extruder_monitor.enable() + + + def handle_connect(self): + self._init_controller() + + + def handle_disconnect(self): + pass + + + def set_test_config(self, gcmd): + if self.has_sync_feedback(): + self.sync_feedback_enabled = gcmd.get_int('SYNC_FEEDBACK_ENABLED', self.sync_feedback_enabled, minval=0, maxval=1) + self.sync_feedback_buffer_range = gcmd.get_float('SYNC_FEEDBACK_BUFFER_RANGE', self.sync_feedback_buffer_range, minval=0.) + self.sync_feedback_buffer_maxrange = gcmd.get_float('SYNC_FEEDBACK_BUFFER_MAXRANGE', self.sync_feedback_buffer_maxrange, minval=0.) + self.sync_feedback_speed_multiplier = gcmd.get_float('SYNC_FEEDBACK_SPEED_MULTIPLIER', self.sync_feedback_speed_multiplier, minval=1., maxval=50) + self.sync_feedback_boost_multiplier = gcmd.get_float('SYNC_FEEDBACK_BOOST_MULTIPLIER', self.sync_feedback_boost_multiplier, minval=1., maxval=50) + self.sync_feedback_extrude_threshold = gcmd.get_float('SYNC_FEEDBACK_EXTRUDE_THRESHOLD', self.sync_feedback_extrude_threshold, above=1.) + self.sync_feedback_debug_log = gcmd.get_int('SYNC_FEEDBACK_DEBUG_LOG', self.sync_feedback_debug_log, minval=0, maxval=1) + + flowguard_enabled = gcmd.get_int('FLOWGUARD_ENABLED', self.flowguard_enabled, minval=0, maxval=1) + if flowguard_enabled != self.flowguard_enabled: + self._config_flowguard_feature(flowguard_enabled) + self.flowguard_max_relief = gcmd.get_float('FLOWGUARD_MAX_RELIEF', self.flowguard_max_relief, above=1.) + + if self.mmu.has_encoder(): + mode = gcmd.get_int('FLOWGUARD_ENCODER_MODE', self.flowguard_encoder_mode, minval=0, maxval=2) + if mode != self.flowguard_encoder_mode: + self.flowguard_encoder_mode = mode + self.set_encoder_mode() + self.flowguard_encoder_max_motion = gcmd.get_float('FLOWGUARD_ENCODER_MAX_MOTION', self.flowguard_encoder_max_motion, above=0.) + + + def get_test_config(self): + msg = "" + if self.has_sync_feedback(): + msg += "\nsync_feedback_enabled = %d" % self.sync_feedback_enabled + msg += "\nsync_feedback_buffer_range = %.1f" % self.sync_feedback_buffer_range + msg += "\nsync_feedback_buffer_maxrange = %.1f" % self.sync_feedback_buffer_maxrange + msg += "\nsync_feedback_speed_multiplier = %.1f" % self.sync_feedback_speed_multiplier + msg += "\nsync_feedback_boost_multiplier = %.1f" % self.sync_feedback_boost_multiplier + msg += "\nsync_feedback_extrude_threshold = %.1f" % self.sync_feedback_extrude_threshold + msg += "\nsync_feedback_debug_log = %d" % self.sync_feedback_debug_log + + msg += "\n\nFLOWGUARD:" + msg += "\nflowguard_enabled = %d" % self.flowguard_enabled + msg += "\nflowguard_max_relief = %.1f" % self.flowguard_max_relief + + if self.mmu.has_encoder(): + msg += "\nflowguard_encoder_mode = %d" % self.flowguard_encoder_mode + msg += "\nflowguard_encoder_max_motion = %.1f" % self.flowguard_encoder_max_motion + return msg + + + def check_test_config(self, param): + return vars(self).get(param) is None + + # + # Sync feedback manager public access... + # + + def set_default_rd(self): + """ + Ensure correct starting rotation distance + """ + gate = self.mmu.gate_selected + if gate < 0: return + + rd = self.mmu.calibration_manager.get_gear_rd(gate) + self.mmu.log_debug("MmuSyncFeedbackManager: Setting default rotation distance for gate %d to %.4f" % (gate, rd)) + self.mmu.set_gear_rotation_distance(rd) + + + def is_enabled(self): + """ + This is whether the user has enabled the sync-feedback feature (the "big" switch) + """ + return self.sync_feedback_enabled + + + def is_active(self): + """ + Returns whether the sync-feedback is currently active (when synced) + """ + return self.active + + + def get_sync_feedback_string(self, state=None, detail=False): + if state is None: + state = self._get_sensor_state() + if (self.mmu.is_enabled and self.sync_feedback_enabled and self.active) or detail: + # Polarity varies slightly between modes on proportional sensor so ask controller + polarity = self.ctrl.polarity(state) + return 'compressed' if polarity > 0 else 'tension' if polarity < 0 else 'neutral' + elif self.mmu.is_enabled and self.sync_feedback_enabled: + return "inactive" + return "disabled" + + + def activate_flowguard(self, eventtime): + if self.flowguard_enabled and not self.flowguard_active: + self.flowguard_active = True + # This resets controller with last good autotuned RD, resets flowguard and resumes autotune + self._reset_controller(eventtime, hard_reset=False) + self.ctrl.autotune.resume() + self.mmu.log_info("FlowGuard monitoring activated and autotune resumed") + + + def deactivate_flowguard(self, eventtime): + if self.flowguard_enabled and self.flowguard_active: + self.flowguard_active = False + self.ctrl.autotune.pause() # Very likley this is a period that we want to exclude from autotuning + self.mmu.log_info("FlowGuard monitoring deactivated and autotune paused") + + + # This is "FlowGuard" on the encoder so manage it here + def set_encoder_mode(self, mode=None): + """ + Changing detection mode so ensure correct clog detection length + """ + if not self.mmu.has_encoder(): return + + # Notify sensor of mode + if mode is None: mode = self.flowguard_encoder_mode + self.mmu.encoder_sensor.set_mode(mode) + + # Figure out the correct detection length based on mode + cdl = self.flowguard_encoder_max_motion + if mode == self.mmu.encoder_sensor.RUNOUT_AUTOMATIC: + cdl = self.mmu.save_variables.allVariables.get(self.mmu.VARS_MMU_CALIB_CLOG_LENGTH, cdl) + + # Notify sensor of detection length + self.mmu.encoder_sensor.set_clog_detection_length(cdl) + + + def adjust_filament_tension(self, use_gear_motor=True, max_move=None): + """ + Relax the filament tension, preferring proportional control if available else sync-feedback sensor switches. + By default uses gear stepper to achive the result but optionally can use just extruder stepper for + extruder entry check using compression sensor 'max_move' is advisory maximum travel distance + Returns distance of the correction move and whether operation was successful (or None if not performed) + """ + has_tension, has_compression, has_proportional = self.get_active_sensors() + max_move = max_move or self.sync_feedback_buffer_maxrange + + if has_proportional: + return self._adjust_filament_tension_proportional() # Doesn't yet support extruder stepper or max_move parameter + + if has_tension or has_compression: + return self._adjust_filament_tension_switch(use_gear_motor=use_gear_motor, max_move=max_move) + + # All sensors must be disabled... + return 0.0, None + + + def wipe_telemetry_logs(self): + """ + Called to wipe any sync debug files on print start + """ + for gate in range(self.mmu.num_gates): + log_path = self._telemetry_log_path(gate) + + # Can't wipe if already synced and active + if gate != self.mmu.gate_selected or not self.active: + if os.path.exists(log_path): + try: + os.remove(log_path) + self.mmu.log_error("REMOVED log_path=%s" % log_path) + except OSError as e: + self.mmu.log_debug("Unable to wipe sync feedback debug log: %s" % log_path) + + + def get_active_sensors(self): + """ + Returns tuple of active sync-feedback sensors + """ + sm = self.mmu.sensor_manager + has_tension = sm.has_sensor(self.mmu.SENSOR_TENSION) + has_compression = sm.has_sensor(self.mmu.SENSOR_COMPRESSION) + has_proportional = sm.has_sensor(self.mmu.SENSOR_PROPORTIONAL) + return has_tension, has_compression, has_proportional + + + def has_sync_feedback(self): + return all(s is not None for s in self.get_active_sensors()) + + + # + # GCODE Commands ----------------------------------------------------------- + # + + cmd_MMU_FLOWGUARD_help = "Enable/disable FlowGuard (clog-tangle detection)" + cmd_MMU_FLOWGUARD_param_help = ( + "MMU_FLOWGUARD: %s\n" % cmd_MMU_FLOWGUARD_help + + "ENABLE = [1|0] enable/disable FlowGuard clog/tangle detection\n" + + "(no parameters for status report)" + ) + def cmd_MMU_FLOWGUARD(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + if not self.sync_feedback_enabled: + self.mmu.log_warning("Sync feedback is disabled or not configured. FlowGuard is unavailable") + return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_FLOWGUARD_param_help), color=True) + return + + enable = gcmd.get_int('ENABLE', None, minval=0, maxval=1) + + if enable is not None: + self._config_flowguard_feature(enable) + return + + # Just report status + if self.flowguard_enabled: + active = " and currently active" if self.flowguard_active else " (not currently active)" + self.mmu.log_always("FlowGuard monitoring feature is enabled%s" % active) + else: + self.mmu.log_always("FlowGuard monitoring feature is disabled") + + + cmd_MMU_SYNC_FEEDBACK_help = "Controls sync feedback and applies filament tension adjustments" + cmd_MMU_SYNC_FEEDBACK_param_help = ( + "MMU_SYNC_FEEDBACK: %s\n" % cmd_MMU_SYNC_FEEDBACK_help + + "ENABLE = [1|0] enable/disable sync feedback control\n" + + "RESET = [1|0] reset sync controller and return RD to last known good value\n" + + "ADJUST_TENSION = [1|0] apply correction to neutralize filament tension\n" + + "AUTOTUNE = [1|0] allow saving of autotuned rotation distance\n" + + "(no parameters for status report)" + ) + def cmd_MMU_SYNC_FEEDBACK(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_bypass(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_SYNC_FEEDBACK_param_help), color=True) + return + + if not self.has_sync_feedback(): + self.mmu.log_warning("No sync-feedback sensors!") + return + + has_tension, has_compression, has_proportional = self.get_active_sensors() + + if not (has_proportional or has_tension or has_compression): + self.mmu.log_warning("No sync-feedback sensors are enabled!") + return + + enable = gcmd.get_int('ENABLE', None, minval=0, maxval=1) + reset = gcmd.get_int('RESET', None, minval=0, maxval=1) + autotune = gcmd.get_int('AUTOTUNE', None, minval=0, maxval=1) + adjust_tension = gcmd.get_int('ADJUST_TENSION', 0, minval=0, maxval=1) + + if enable is not None: + self.sync_feedback_enabled = enable + self.mmu.log_always("Sync feedback feature is %s" % ("enabled" if enable else "disabled")) + + if reset is not None and self.sync_feedback_enabled: + self.mmu.log_always("Sync feedback reset") + eventtime = self.mmu.reactor.monotonic() + self._reset_controller(eventtime) + + if autotune is not None: + self.mmu.autotune_rotation_distance = autotune + self.mmu.log_always("Save Autotuned rotation distance feature is %s" % ("enabled" if autotune else "disabled")) + + if adjust_tension: + try: + with self.mmu.wrap_sync_gear_to_extruder(): # Cannot adjust sync feedback sensor if gears are not synced + with self.mmu._wrap_suspend_filament_monitoring(): # Avoid spurious runout during tiny corrective moves (unlikely) + actual,success = self.adjust_filament_tension() + if success: + self.mmu.log_info("Neutralized tension after moving %.2fmm" % actual) + elif success is False: + self.mmu.log_warning("Moved %.2fmm without neutralizing tension" % actual) + else: + self.mmu.log_warning("Operation not possible. Perhaps sensors are disabled?") + + except MmuError as ee: + self.mmu.log_error("Error in MMU_SYNC_FEEDBACK: %s" % str(ee)) + + if enable is None and autotune is None and not adjust_tension: + # Just report status + if self.sync_feedback_enabled: + mode = self.ctrl.get_type_mode() + active = " and currently active" if self.active else " (not currently active)" + msg = "Sync feedback feature with type-%s sensor is enabled%s\n" % (mode, active) + + rd_start = self.mmu.calibration_manager.get_gear_rd() + rd_current = self.ctrl.get_current_rd() + rd_rec = self.ctrl.autotune.get_rec_rd() + msg += "- Current RD: %.2f, Autotune recommended: %.2f, Default: %.2f\n" % (rd_current, rd_rec, rd_start) + + has_tension, has_compression, has_proportional = self.get_active_sensors() + msg += "- State: %s\n" % self.get_sync_feedback_string(detail=True) + msg += "- FlowGuard: %s" % ("Active" if self.flowguard_active else "Inactive") + if has_proportional: + msg += " (Flowrate: %.1f%%)" % self.flow_rate + + self.mmu.log_always(msg) + + else: + self.mmu.log_always("Sync feedback feature is disabled") + + + def get_status(self, eventtime=None): + self.flowguard_status['encoder_mode'] = self.flowguard_encoder_mode # Ok to mutate status + return { + 'sync_feedback_state': self.get_sync_feedback_string(), + 'sync_feedback_enabled': self.is_enabled(), + 'sync_feedback_bias_raw': self._get_sync_bias_raw(), + 'sync_feedback_bias_modelled': self._get_sync_bias_modelled(), + 'sync_feedback_flow_rate': self.flow_rate, + 'flowguard': self.flowguard_status, + } + + + # + # Internal implementation -------------------------------------------------- + # + + def _telemetry_log_path(self, gate=None): + if gate is None: gate = self.mmu.gate_selected + + logfile_path = self.mmu.printer.start_args['log_file'] + dirname = os.path.dirname(logfile_path) + + if not dirname: + dirname = "/tmp" + + return os.path.join(dirname, 'sync_%d.jsonl' % gate) + + + def _handle_mmu_synced(self, eventtime=None): + """ + Event indicating that gear stepper is now synced with extruder + """ + if not self.mmu.is_enabled: return + if eventtime is None: eventtime = self.mmu.reactor.monotonic() + + msg = "MmuSyncFeedbackManager: Synced MMU to extruder%s" % (" (sync feedback activated)" if self.sync_feedback_enabled else "") + if self.mmu.mmu_machine.filament_always_gripped: + self.mmu.log_debug(msg) + else: + self.mmu.log_info(msg) + + if self.active: return + + # Enable sync feedback + self.active = True + self.new_autotuned_rd = None + + # Throw away current autotune info and reset rd + self._reset_controller(eventtime) + + # Turn on extruder movement events + self.extruder_monitor.register_callback(self._handle_extruder_movement, self.sync_feedback_extrude_threshold) + + + def _handle_mmu_unsynced(self, eventtime=None): + """ + Event indicating that gear stepper has been unsynced from extruder + """ + if not (self.mmu.is_enabled and self.sync_feedback_enabled and self.active): return + if eventtime is None: eventtime = self.mmu.reactor.monotonic() + + msg = "MmuSyncFeedbackManager: Unsynced MMU from extruder%s" % (" (sync feedback deactivated)" if self.sync_feedback_enabled else "") + if self.mmu.mmu_machine.filament_always_gripped: + self.mmu.log_debug(msg) + else: + self.mmu.log_info(msg) + + if not self.active: return + + # Deactivate sync feedback + self.active = False + + if self.new_autotuned_rd is not None: + self.mmu.log_info("MmuSyncFeedbackManager: New Autotuned rotation distance (%.4f) for gate %d\n" % (self.new_autotuned_rd, self.mmu.gate_selected)) + self.mmu.calibration_manager.update_gear_rd(self.new_autotuned_rd) + + # Restore default (last tuned) rotation distance + self.set_default_rd() + + # Optional but let's turn off extruder movement events + self.extruder_monitor.remove_callback(self._handle_extruder_movement) + + + def _handle_extruder_movement(self, eventtime, move): + """ + Event call when extruder has moved more than threshold. This also allows for + periodic rotation_distance adjustment, autotune and flowguard checking + """ + if not (self.mmu.is_enabled and self.sync_feedback_enabled and self.active): return + if eventtime is None: eventtime = self.mmu.reactor.monotonic() + + self.mmu.log_trace("MmuSyncFeedbackManager: Extruder movement event, move=%.1f" % move) + + state = self._get_sensor_state() + status = self.ctrl.update(eventtime, move, state) + self._process_status(eventtime, status) + + + def _handle_sync_feedback(self, eventtime, state): + """ + Event call when sync-feedback discrete state changes. + 'state' should be -1 (tension), 0 (neutral), 1 (compressed) + or can be a proportional float value between -1.0 and 1.0 + """ + if not (self.mmu.is_enabled and self.sync_feedback_enabled and self.active): return + if eventtime is None: eventtime = self.mmu.reactor.monotonic() + + msg = "MmuSyncFeedbackManager: Sync state changed to %s" % (self.get_sync_feedback_string(state)) + if self.mmu.mmu_machine.filament_always_gripped: + self.mmu.log_debug(msg) + else: + self.mmu.log_info(msg) + + move = self.extruder_monitor.get_and_reset_accumulated(self._handle_extruder_movement) + status = self.ctrl.update(eventtime, move, state) + self._process_status(eventtime, status) + + + def _process_status(self, eventtime, status): + """ + Common logic to process the rotation distance recommendations of the sync controller + """ + output = status['output'] + + # Handle estimated sensor position + self.estimated_state = output['sensor_ui'] + + # Handle flowguard trip + self.flowguard_status = dict(output['flowguard']) + self.flowguard_status['enabled'] = bool(self.flowguard_enabled) + f_trigger = self.flowguard_status.get('trigger', None) + f_reason = self.flowguard_status.get('reason', "") + if f_trigger: + if self.flowguard_enabled and self.flowguard_active: + self.mmu.log_error("FlowGuard detected a %s.\nReason for trip: %s" % (f_trigger, f_reason)) + + # Pick most appropriate sensor to assign event to (primariliy for optics) + has_tension, has_compression, has_proportional = self.get_active_sensors() + + if has_proportional: + sensor_key = self.mmu.SENSOR_PROPORTIONAL + elif has_compression and not has_tension: + sensor_key = self.mmu.SENSOR_COMPRESSION + elif has_tension and not has_compression: + sensor_key = self.mmu.SENSOR_TENSION + elif f_trigger == "clog": + sensor_key = self.mmu.SENSOR_COMPRESSION + else: # "tangle" + sensor_key = self.mmu.SENSOR_TENSION + sm = self.mmu.sensor_manager + sensor = sm.sensors.get(sensor_key) + + sensor.runout_helper.note_clog_tangle(f_trigger) + self.deactivate_flowguard(eventtime) + else: + self.mmu.log_debug("FlowGuard detected a %s, but handling is disabled.\nReason for trip: %s" % (f_trigger, f_reason)) + self.ctrl.flowguard.reset() # Prevent repetitive messages + + # Handle new autotune suggestions + autotune = output['autotune'] + rd = autotune.get('rd', None) + note = autotune.get('note', None) + save = autotune.get('save', None) + if rd is not None: + msg = "MmuSyncFeedbackManager: Autotune suggested new operational reference rd: %.4f\n%s" % (rd, note) + if save and self.mmu.autotune_rotation_distance: + self.new_autotuned_rd = rd + self.mmu.log_debug(msg) + + # Always update instaneous gear stepper rotation_distance + rd_current, rd_prev, rd_tuned = output['rd_current'], output['rd_prev'], output['rd_tuned'] + if rd_current != rd_prev: + self.mmu.log_debug("MmuSyncFeedbackManager: Altered rotation distance for gate %d from %.4f to %.4f" % (self.mmu.gate_selected, rd_prev, rd_current)) + self.mmu.set_gear_rotation_distance(rd_current) + + # Proportional sensor (with autotune) allows for estimation of flow rate! + if self.mmu.sensor_manager.has_sensor(self.mmu.SENSOR_PROPORTIONAL): + # if rd_current > rd_true then flowrate must be reduced + self.flow_rate = round(min(1.0, (rd_tuned / rd_current)) * 100., 2) + + + def _reset_controller(self, eventtime, hard_reset=True): + """ + hard_reset: Completely reset sync-feedback: throw away autotune info, reset rd to + last calibrated value. Typically called when handling sync but also can + be explicitly called but MMU_SYNC_FEEDBACK command + soft_reset: Rebase sync-feedback to last autotuned value. Typically called when + resuming flowguard (after some activity we want to exclude from tuning) + """ + # Allow dynamic changing of effective "sensor type" based on currently enabled sensors + self.ctrl.cfg.sensor_type = self._get_sensor_type() + + # Reset controller with initial rd and sensor reading (will also reset flowguard and autotune on hard_reset) + starting_state = self._get_sensor_state() + self.estimated_state = starting_state + if hard_reset: + rd_start = self.mmu.calibration_manager.get_gear_rd() + else: + rd_start = self.ctrl.autotune.get_rec_rd() + status = self.ctrl.reset(eventtime, rd_start, starting_state, log_file=self._telemetry_log_path(), hard_reset=hard_reset) + self._process_status(eventtime, status) # May adjust rotation_distance + + + def _init_controller(self): + """ + The controller logic is in a completely standalone module for simulation + and debugging purposes so instantiate it here with current config + Returns: the SyncController object + """ + rd_start = self.mmu.calibration_manager.get_gear_rd() + cfg = SyncControllerConfig( + log_sync = bool(self.sync_feedback_debug_log), + buffer_range_mm = self.sync_feedback_buffer_range, + buffer_max_range_mm = self.sync_feedback_buffer_maxrange, + sensor_type = self._get_sensor_type(), + use_twolevel_for_type_p = self.sync_feedback_force_twolevel, + rd_start = rd_start, + flowguard_relief_mm = self.flowguard_max_relief, + ) + self.ctrl = SyncController(cfg) + return self.ctrl + + + def _config_flowguard_feature(self, enable): + if enable: + self.mmu.log_info("FlowGuard monitoring feature %senabled" % ("already " if self.flowguard_enabled else "")) + if not self.flowguard_enabled: + self.flowguard_enabled = True + if self.ctrl: + self.ctrl.flowguard.reset() + else: + self.mmu.log_info("FlowGuard monitoring feature %sdisabled" % ("already " if not self.flowguard_enabled else "")) + self.flowguard_enabled = False + + + def _get_sync_bias_raw(self): + return float(self._get_sensor_state()) + + + def _get_sync_bias_modelled(self): + if self.mmu.is_enabled and self.sync_feedback_enabled and self.active and self.mmu.is_printing(): + # This is a better representation for UI when the controller is active + return self.estimated_state + else: + # Otherwise return the real state + return float(self._get_sensor_state()) + + + def _get_sensor_state(self): + """ + Get current tension state based on current sensor feedback. + Returns float in range [-1.0 .. 1.0] for proportional, {-1, 0, 1) for switch + """ + sm = self.mmu.sensor_manager + has_proportional = sm.has_sensor(self.mmu.SENSOR_PROPORTIONAL) + if has_proportional: + sensor = sm.sensors.get(self.mmu.SENSOR_PROPORTIONAL) + return sensor.get_status(0).get('value', 0.) + + tension_active = sm.check_sensor(self.mmu.SENSOR_TENSION) + compression_active = sm.check_sensor(self.mmu.SENSOR_COMPRESSION) + + if tension_active == compression_active: + ss = self.SF_STATE_NEUTRAL + elif compression_active: + ss = self.SF_STATE_COMPRESSION + elif tension_active: + ss = self.SF_STATE_TENSION + else: + ss = self.SF_STATE_NEUTRAL + return ss + + + def _get_sensor_type(self): + """ + Return symbolic sensor type based on current active sensors + "P" => proportional z ∈ [-1, +1]; enables EKF + "D" => discrete dual-switch z ∈ {-1,0,+1}; Optional EKF + "CO" => compression-only switch z ∈ {0,+1} + "TO" => tension_only switch z ∈ {-1,0} + """ + has_tension, has_compression, has_proportional = self.get_active_sensors() + return ( + "P" if has_proportional + else "D" if has_compression and has_tension + else "CO" if has_compression + else "TO" if has_tension + else "Unknown" + ) + + + def _adjust_filament_tension_switch(self, use_gear_motor=True, max_move=None): + """ + Helper to relax filament tension using the sync-feedback buffer. This can be performed either with the + gear motor (default) or extruder motor (which is also good as an extruder loading check) + Returns distance moved and whether operation was successful and neutral was found (or None if not performed) + """ + fhomed = None + actual = 0. + + state = self._get_sensor_state() + if state == self.SF_STATE_NEUTRAL: + return actual, True + + has_tension, has_compression, _ = self.get_active_sensors() + if not (has_tension or has_compression): + self.mmu.log_debug("No active sync feedback sensors; cannot adjust filament tension") + return actual, fhomed + + max_move = max_move or self.sync_feedback_buffer_maxrange + self.mmu.log_debug("Monitoring extruder entrance transition for up to %.1fmm..." % max_move) + + motor = "gear" if use_gear_motor else "extruder" + speed = min(self.mmu.gear_homing_speed, self.mmu.extruder_homing_speed) # Keep this tension adjustment slow + + # Determine direction based on state and motor type + # Note that if sync_feedback_buffer_range is 0, it implies + # special case where neutral point overlaps both sensors + if state == self.SF_STATE_COMPRESSION: + self.mmu.log_debug("Relaxing filament compression") + direction = -1 if use_gear_motor else 1 + + if self.sync_feedback_buffer_range == 0: + msg = "Homing to tension sensor" + sensor = self.mmu.SENSOR_TENSION + homing_dir = 1 + elif has_compression: + msg = "Reverse homing off compression sensor" + sensor = self.mmu.SENSOR_COMPRESSION + homing_dir = -1 + else: + msg = "Homing to tension sensor" + sensor = self.mmu.SENSOR_TENSION + homing_dir = 1 + + else: + # Tension state + self.mmu.log_debug("Relaxing filament tension") + direction = 1 if use_gear_motor else -1 + + if self.sync_feedback_buffer_range == 0: + msg = "Homing to compression sensor" + sensor = self.mmu.SENSOR_COMPRESSION + homing_dir = 1 + elif has_tension: + msg = "Reverse homing off tension sensor" + sensor = self.mmu.SENSOR_TENSION + homing_dir = -1 + else: + msg = "Homing to compression sensor" + sensor = self.mmu.SENSOR_COMPRESSION + homing_dir = 1 + + actual,fhomed,_,_ = self.mmu.trace_filament_move( + msg, + max_move * direction, + speed=speed, + motor=motor, + homing_move=homing_dir, + endstop_name=sensor, + ) + + if fhomed and self.sync_feedback_buffer_range != 0: + if use_gear_motor: + # Move just a little more to find perfect neutral spot between sensors + _,_,_,_ = self.mmu.trace_filament_move("Centering sync feedback buffer", (self.sync_feedback_buffer_range * direction) / 2.) + else: + self.mmu.log_debug("Failed to reach neutral filament tension after moving %.1fmm" % max_move) + + return actual, fhomed + + + def _adjust_filament_tension_proportional(self): + """ + Helper to relax filament tension using the proportional sync-feedback buffer. + Returns: actual distance moved (mm), success bool + """ + + # nudge_mm: per-move adjustment distance in mm (small feed or retract) + # neutral_band: absolute value of proportional sensor reading considered "neutral". + # This can be loosely interpreted as a % over the max range of detection of the sensor. + # For example for a sensor with 14mm range, a 0.15 tolerance is approx 1.4mm either side of centre. + # settle_time: delay between moves to allow sensor feedback to update + # timeout_s: hard stop to avoid hanging if the sensor never clears + neutral_band = 0.1 + settle_time = 0.1 + timeout_s = 10.0 + + # Wait for move queues to clear + self.mmu.mmu_toolhead.quiesce() + + # sanity-check parameters before doing anything + # neutral band needs to have a non zero and non trivial value. Enforce 5% (0.05) + # as the lower limit of acceptable neutral band tolerance. + if neutral_band < 0.05: + neutral_band = 0.05 + + # maxrange is full end-to-end sensor span; use half as the per-side budget from neutral to either end + maxrange_span_mm = float(self.sync_feedback_buffer_maxrange) + if maxrange_span_mm <= 0.0: + self.mmu.log_debug("Proportional adjust skipped: buffer maxrange <= 0") + return 0., False + per_side_budget_mm = 0.5 * maxrange_span_mm + nudge_mm = per_side_budget_mm * neutral_band + + # Cap total nudge iterations to stay within the overall sensor range + max_steps = int(math.ceil(maxrange_span_mm / nudge_mm)) + + moved_total_mm = 0.0 # total net distance moved during this adjustment + moved_nudges_mm = 0.0 # sum of all nudge moves + moved_initial_mm = 0.0 # size of the initial proportional move (if any) + steps = 0 # total moves performed + t_start = self.mmu.reactor.monotonic() + + # --- Initial proportional correction --- + # Negative sensor state = tension -> feed filament. positive sensor state = compression -> retract filament + prop_state = self._get_sensor_state() # [-1..+1], 0 ≈ neutral + if abs(prop_state) > neutral_band: + # Initial move distance as a proportion to how off centre we are based on the sensor readings. + # this will get the sensor close but likely will need a few fine adjustments (nudges) to get it + # within the centre range depending on how large the bowden tube slack is. + initial_move_mm = -prop_state * per_side_budget_mm + if abs(initial_move_mm) >= nudge_mm: + self.mmu.trace_filament_move( + "Proportional initial adjust - extruder load", + initial_move_mm, motor="gear", wait=True + ) + moved_total_mm += initial_move_mm + moved_initial_mm = initial_move_mm + steps += 1 + try: + self.mmu.reactor.pause(settle_time) + except Exception: + time.sleep(settle_time) + + # --- Check proportional sensor state after initial move and return if within neutral deadband --- + prop_state = self._get_sensor_state() + if abs(prop_state) <= neutral_band: + self.mmu.log_info( + "Proportional adjust: neutral after initial " + "(nudge=%.2fmm, initial=%.2fmm, nudges=%.2fmm, total=%.2fmm, steps=%d, final_state=%.3f, success=yes)" % + (nudge_mm, moved_initial_mm, moved_nudges_mm, moved_total_mm, steps, prop_state) + ) + return moved_total_mm, True + + # --- Fine adjustment loop (nudges) --- + while abs(moved_total_mm) < maxrange_span_mm and steps < max_steps: + prop_state = self._get_sensor_state() + # timeout safety: avoid hanging if the sensor never clears + if (self.mmu.reactor.monotonic() - t_start) > timeout_s: + self.mmu.log_info( + "Proportional adjust: timed out " + "(nudge=%.2fmm, initial=%.2fmm, nudges=%.2fmm, total=%.2fmm, steps=%d, final_state=%.3f)" % + (nudge_mm, moved_initial_mm, moved_nudges_mm, moved_total_mm, steps, prop_state) + ) + return moved_total_mm, False + + if abs(prop_state) <= neutral_band: + # confirm neutral after a short wait + try: + self.mmu.reactor.pause(settle_time) + except Exception: + time.sleep(settle_time) + prop_state = self._get_sensor_state() + if abs(prop_state) <= neutral_band: + break + + # Direction: tension -> feed forward; compression -> retract + nudge_move_mm = nudge_mm if prop_state < 0.0 else -nudge_mm + # don't exceed the end to end sensor span (maxrange_span_mm). Serves as "ultimate" failsafe. + if abs(moved_total_mm + nudge_move_mm) >= maxrange_span_mm: + self.mmu.log_info( + "Proportional adjust: aborted (exceeded buffer) " + "(nudge=%.2fmm, initial=%.2fmm, nudges=%.2fmm, total=%.2fmm, steps=%d, final_state=%.3f)" % + (nudge_mm, moved_initial_mm, moved_nudges_mm, moved_total_mm, steps, prop_state) + ) + return moved_total_mm, False + + self.mmu.trace_filament_move( + "Proportional adjust - extruder load", + nudge_move_mm, motor="gear", wait=True + ) + moved_total_mm += nudge_move_mm + moved_nudges_mm += nudge_move_mm + steps += 1 + try: + self.mmu.reactor.pause(settle_time) + except Exception: + time.sleep(settle_time) + + # Final check + final_state = self._get_sensor_state() + success = abs(final_state) <= neutral_band + self.mmu.log_info( + "Proportional adjust: complete " + "(nudge=%.2fmm, initial=%.2fmm, nudges=%.2fmm, total=%.2fmm, steps=%d, final_state=%.3f, success=%s)" % + (nudge_mm, moved_initial_mm, moved_nudges_mm, moved_total_mm, steps, final_state, "yes" if success else "no") + ) + return moved_total_mm, success diff --git a/extras/mmu/mmu_test.py b/extras/mmu/mmu_test.py new file mode 100644 index 000000000000..c372adc83519 --- /dev/null +++ b/extras/mmu/mmu_test.py @@ -0,0 +1,864 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Define internal test operations to aid development. Note these tests are "raw" +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import random, logging, math + +from ..mmu_sensors import MmuSensors + +# Happy Hare imports +from .. import mmu_machine +from ..mmu_machine import MmuToolHead + +# MMU subcomponent clases +from .mmu_shared import * +from .mmu_utils import PurgeVolCalculator, DebugStepperMovement + +class SyncStateTest(object): + ''' + This class describes what a sync_state test case is and offers methods to manipulate them. + ''' + def __init__(self, compression_sensor, tension_sensor, compression_state, tension_state, toggle_compression, toggle_tension, triggered_sensor, id): + self.compression_sensor = compression_sensor.runout_helper.sensor_enabled + self.tension_sensor = tension_sensor.runout_helper.sensor_enabled + self.compression_state = compression_state + self.tension_state = tension_state + self.toggle_compression = toggle_compression + self.toggle_tension = toggle_tension + self.expected = None + self.triggered_sensor = triggered_sensor + self.id = id + self.set_expected() + + def set_expected(self): + ''' + Return the expected float state with given inputs and current sensor states. + ''' + if self.compression_sensor and self.tension_sensor: + if self.compression_state == self.tension_state: + self.expected = 0. + elif self.compression_state and not self.tension_state: + self.expected = 1. + elif not self.compression_state and self.tension_state: + self.expected = -1. + elif self.compression_sensor: + if self.compression_state: + self.expected = 1. + else: + self.expected = -1. + elif self.tension_sensor: + if self.tension_state: + self.expected = -1. + else: + self.expected = 1. + else : + self.expected = 0. + + def __str__(self): + return "compression_sensor=%s, tension_sensor=%s, compression_state=%s, tension_state=%s, toggle_compression=%s, toggle_tension=%s, triggered_sensor=%s" % (self.compression_sensor, self.tension_sensor, self.compression_state, self.tension_state, self.toggle_compression, self.toggle_tension, self.triggered_sensor) + + def __repr__(self): + return self.__str__() + +class MmuTest: + + def __init__(self, mmu): + self.mmu = mmu + mmu.gcode.register_command('_MMU_TEST', self.cmd_MMU_TEST, desc = self.cmd_MMU_TEST_help) # Internal for testing + + cmd_MMU_TEST_help = "Internal Happy Hare developer tests" + def cmd_MMU_TEST(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.mmu.log_info("SYNC_STATE=['compression'|'tension'|'both'|neutral] : Set the sync state") + self.mmu.log_info("SYNC_EVENT=[-1.0 ... 1.0] : Generate sync feedback event") + self.mmu.log_info("DUMP_UNICODE=1 : Display special characters used in display") + self.mmu.log_info("RUN_SEQUENCE=1 : Run through the set of sequence macros tracking time") + self.mmu.log_info("GET_POS=1 : Fetch the current filament position state") + self.mmu.log_info("SET_POS= : Set the current filament position state") + self.mmu.log_info("SET_RD= [GATE=]: Update the specified gate's rotation distance") + self.mmu.log_info("GET_POSITION=1 : Fetch the current filament position") + self.mmu.log_info("SET_POSITION= : Fetch the current filament position") + self.mmu.log_info("SYNC_LOAD_TEST=1 : Hammer stepper syncing and movement. Params: LOOP|HOME|WAIT") + self.mmu.log_info("REALISTIC_SYNC_TEST=1 : Load test normal stepper syncing and movement. Params: LOOP|SELECT|ENDSTOP") + self.mmu.log_info("QUIESCE_TEST=1 : Quick test of problematic sync changes") + self.mmu.log_info("SEL_MOVE=1 : Selector homing move. Params: MOVE|SPEED|ACCEL|WAIT|LOOP") + self.mmu.log_info("SEL_HOMING_MOVE=1 : Selector homing move. Params: MOVE|SPEED|ACCEL|WAIT|LOOP|ENDSTOP") + self.mmu.log_info("SEL_LOAD_TEST=1 : Load test selector movements. Params: LOOP|ENDSTOP") + self.mmu.log_info("TTC_TEST=1 : Provoke known TTC condition. Params: LOOP|MIX|DEBUG|WAIT") + self.mmu.log_info("TTC_TEST2=1 : Provoke known TTC condition. Params: LOOP|MIX|DEBUG|WAIT") + self.mmu.log_info("TTC_TEST3=1 : Provoke known TTC condition. Params: LOOP|MIX|DEBUG|WAIT") + self.mmu.log_info("STEPCOMPRESS_TEST=1 : Provoke stepcompress error. Parms: LOOP|MIX|DEBUG|WAIT") + self.mmu.log_info("AUTO_CALIBRATE=1 [GATE=] [DIRECTION=] [RATIO=] [HOMING=] : Call auto-calibrate function directly") + self.mmu.log_info("GATE_MOTOR=n : Select the specified gear motor on type-B designs") + self.mmu.log_info("SYNC_G2E=1 : Sync gear to extruder (user mode)") + self.mmu.log_info("SYNC_E2G=1 [EXTRUDER_ONLY=] : Sync extruder to gear optionally just the extruder on rail") + self.mmu.log_info("UNSYNC=1 [GEAR_ONLY=]: Unsync (user mode) or unsynced with assuption of no extruder movement") + return + + def log(msg): + self.mmu.log_info(msg) + logging.info("MMU: %s" % msg) + + try: + self.mmu._is_running_test = True + have_run_test = False + + sync_state = gcmd.get('SYNC_STATE', None) + if sync_state is not None: + have_run_test = True + # Create phony sensors for testing purposes (will be removed after the test) + mmu_sensors = self.mmu.printer.lookup_object("mmu_sensors") + config = self.mmu.config.getsection('mmu_sensors') + sensors_to_remove = [] + compression_sensor_filament_present = tension_sensor_filament_present = False + + # Use the temporary sensors for the test if the real ones are not present or disabled + compression_test_sensor = mmu_sensors.sensors.get(self.mmu.SENSOR_COMPRESSION, None) + if compression_test_sensor is None or not compression_test_sensor.runout_helper.sensor_enabled: + mmu_sensors._create_mmu_sensor(config, self.mmu.SENSOR_COMPRESSION, None, 'test_'+self.mmu.SENSOR_COMPRESSION+'_pin', 0, button_handler=mmu_sensors._sync_compression_callback) + compression_test_sensor = mmu_sensors.sensors.get(self.mmu.SENSOR_COMPRESSION, None) + sensors_to_remove.append(self.mmu.SENSOR_COMPRESSION) + tension_test_sensor = mmu_sensors.sensors.get(self.mmu.SENSOR_TENSION, None) + if tension_test_sensor is None or not tension_test_sensor.runout_helper.sensor_enabled: + mmu_sensors._create_mmu_sensor(config, self.mmu.SENSOR_TENSION, None, 'test_'+self.mmu.SENSOR_TENSION+'_pin', 0, button_handler=mmu_sensors._sync_tension_callback) + tension_test_sensor = mmu_sensors.sensors.get(self.mmu.SENSOR_TENSION, None) + sensors_to_remove.append(self.mmu.SENSOR_TENSION) + + if sync_state == 'loop': + nb_iterations = gcmd.get_int('LOOP', 1000, minval=1, maxval=10000000) + gathered_states = [] + tests = [] + global finished + finished = False + # save the sensor state + saved_compr = compression_test_sensor.runout_helper.sensor_enabled + saved_tens = tension_test_sensor.runout_helper.sensor_enabled + + def gather_state(state): + self.mmu.log_trace(" -- Gathered state: %s" % len(gathered_states)) + gathered_states.append(state) + def wait_for_results(): + while len(gathered_states) < len(tests): + pass + self.mmu.log_trace(" -- All states were gatherred : %s" % len(gathered_states)) + display_results() + global finished + finished = True + def wait_run(): + global finished + while not finished: + pass + finished = False + def display_results(): + nb_tests_by_expected = {} + # For each configuration print the number of times it was run + self.mmu.log_debug("Configuration repartition") + nb_hits = {} + for comp in [None, True, False]: + for tens in [None, True, False]: + for t in tests: + if (comp, tens) not in nb_hits: + nb_hits.update({(comp, tens): 0}) + if (t.compression_state, t.tension_state) == (comp, tens): + nb_hits[(comp, tens)] += 1 + # Print the hits from most to least frequent + for key, value in sorted(nb_hits.items(), key=lambda item: item[1], reverse=True): + if value : self.mmu.log_debug(" compression : %s - tension : %s -> %s" % (key[0], key[1], value)) + + # Group by expected result and print how many tests should result in that state + self.mmu.log_debug("Expected state repartition") + for expected in [1.,0.,-1.]: + count = len([1 for t in tests if t.expected == expected]) + self.mmu.log_debug(" Expected state %s -> %s" % (expected, count)) + nb_tests_by_expected.update({expected: count}) + + mismatches = {} + for i, test in enumerate(tests): + if str(test) not in mismatches: + mismatches[str(test)] = {'test' : test, 'count' : 0} + if test.expected != gathered_states[i]: + self.mmu.log_debug("MISMATCH on test id:%s (expected : %s) -> %s" % (str(test.id), test.expected, gathered_states[i])) + self.mmu.log_debug(" Test : %s" % (str(test))) + mismatches[str(test)]['count'] += 1 + # Display mismatches + nb_mismatches = sum([v['count'] for v in mismatches.values()]) + self.mmu.log_info("Total Mismatches: "+str(nb_mismatches) + '/' + str(len(tests)) + ' (' + str(round(nb_mismatches / len(tests) * 100, 2)) +' %)') + + if mismatches: + # Sort by most mismatches.values (highest first) + mismatches = dict(sorted(mismatches.items(), key=lambda item: item[1]['count'], reverse=True)) + for test_str, info in mismatches.items(): + if info['count'] : self.mmu.log_debug("MISMATCH: %s (expected : %s) -> %s" % (test_str, info['test'].expected, info['count'])) + # Summary displaying which expected state has which percentage of total errors + for expected in [1,0,-1]: + if nb_tests_by_expected[expected]: + nb_err_count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected]) + self.mmu.log_debug(">>>>>> Expected state " + str(expected) + " -> " + str(nb_err_count) + '/' + str(nb_tests_by_expected[expected]) + ' (' + str(round(nb_err_count / nb_tests_by_expected[expected] * 100, 2)) + ' %)') + self.mmu.log_debug(" Edge detection error repartition") + # Group by compression rising edge + count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected and info['test'].toggle_compression == 'rising edge']) + if count : self.mmu.log_debug(" " + str(count) + '/' + str(nb_err_count) + ' (' + str(round(((count / nb_err_count) if nb_err_count else 0) * 100, 2)) + ' %) ' + 'compression rising edge') + # Group by compression falling edge + count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected and info['test'].toggle_compression == 'falling edge']) + if count : self.mmu.log_debug(" " + str(count) + '/' + str(nb_err_count) + ' (' + str(round(((count / nb_err_count) if nb_err_count else 0) * 100, 2)) + ' %) ' + 'compression falling edge') + # Group by tension rising edge + count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected and info['test'].toggle_tension == 'rising edge']) + if count : self.mmu.log_debug(" " + str(count) + '/' + str(nb_err_count) + ' (' + str(round(((count / nb_err_count) if nb_err_count else 0) * 100, 2)) + ' %) ' + 'tension rising edge') + # Group by tension falling edge + count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected and info['test'].toggle_tension == 'falling edge']) + if count : self.mmu.log_debug(" " + str(count) + '/' + str(nb_err_count) + ' (' + str(round(((count / nb_err_count) if nb_err_count else 0) * 100, 2)) + ' %) ' + 'tension falling edge') + + else: + self.mmu.log_info("No mismatches") + + self.mmu.printer.register_event_handler("mmu:sync_feedback_finished", gather_state) + self.mmu.printer.register_event_handler("mmu:test_gen_finished", wait_for_results) + # randomly remove tension or compression sensor + for sensor_scenario in ['compression_only', 'tension_only', 'both', 'none']: + if sensor_scenario == 'compression_only': + compression_test_sensor.runout_helper.sensor_enabled = True + tension_test_sensor.runout_helper.sensor_enabled = False + elif sensor_scenario == 'tension_only': + compression_test_sensor.runout_helper.sensor_enabled = False + tension_test_sensor.runout_helper.sensor_enabled = True + elif sensor_scenario == 'both': + compression_test_sensor.runout_helper.sensor_enabled = True + tension_test_sensor.runout_helper.sensor_enabled = True + else: + compression_test_sensor.runout_helper.sensor_enabled = False + tension_test_sensor.runout_helper.sensor_enabled = False + + self.mmu.log_info(">>>>>> Testing sensor configuration '%s'" % sensor_scenario) + + while len(tests) < nb_iterations: + compression_sensor_filament_present = compression_test_sensor.runout_helper.filament_present + tension_sensor_filament_present = tension_test_sensor.runout_helper.filament_present + toggle_compression = "no change" + toggle_tension = "no change" + # generate a random sync state + if random.choice([True, False]): # we consider only one sensor can change state at a time + compression_sensor_filament_present = random.choice([True, False]) + if compression_sensor_filament_present != compression_test_sensor.runout_helper.filament_present: + compression_test_sensor.runout_helper.note_filament_present(compression_sensor_filament_present) + triggered_sensor = "compression" + toggle_compression = "rising edge" if compression_sensor_filament_present else "falling edge" + self.mmu.log_trace(" -- Generated test: %s" % len(tests)) + tests.append(SyncStateTest(compression_test_sensor, tension_test_sensor, compression_sensor_filament_present, tension_sensor_filament_present, toggle_compression, toggle_tension, triggered_sensor, len(tests))) + else : + tension_sensor_filament_present = random.choice([True, False]) + if tension_sensor_filament_present != tension_test_sensor.runout_helper.filament_present: + tension_test_sensor.runout_helper.note_filament_present(tension_sensor_filament_present) + triggered_sensor = "tension" + toggle_tension = "rising edge" if tension_sensor_filament_present else "falling edge" + self.mmu.log_trace(" -- Generated test: %s" % len(tests)) + tests.append(SyncStateTest(compression_test_sensor, tension_test_sensor, compression_sensor_filament_present, tension_sensor_filament_present, toggle_compression, toggle_tension, triggered_sensor, len(tests))) + + + self.mmu.log_trace(" -- All %d were generated" % len(tests)) + self.mmu.printer.send_event("mmu:test_gen_finished") + wait_run() + gathered_states = [] + tests = [] + # remove test sensors and associated config + ppins = self.mmu.printer.lookup_object('pins') + for sensor in sensors_to_remove: + self.mmu.printer.objects.pop("filament_switch_sensor %s_sensor" % sensor) + config.fileconfig.pop("filament_switch_sensor %s_sensor" % sensor) + mmu_sensors.sensors.pop(sensor) + share_name = "%s:%s" % (ppins.parse_pin('test_'+sensor+'_pin')['chip_name'], ppins.parse_pin('test_'+sensor+'_pin')['pin']) + ppins.active_pins.pop(share_name) + for cmd, (__, val) in self.mmu.gcode.mux_commands.items() : + if ("%s_sensor" % sensor) in [k for k in [v for v in val.keys() if v]]: + self.mmu.gcode.mux_commands[cmd][1].pop(("%s_sensor" % sensor)) + + # restore the original sensor state + compression_test_sensor.runout_helper.sensor_enabled = saved_compr + tension_test_sensor.runout_helper.sensor_enabled = saved_tens + self.mmu.log_info("See mmu.log for a more details") + + else: + if sync_state == 'compression': + if compression_test_sensor is not None: + self.mmu.log_info("Setting compression sensor to 'detected'") + compression_sensor_filament_present = True + if tension_test_sensor is not None: + self.mmu.log_info("Setting tension sensor to 'not detected'") + tension_sensor_filament_present = False + elif sync_state == 'tension': + if compression_test_sensor is not None: + self.mmu.log_info("Setting compression sensor to 'not detected'") + compression_sensor_filament_present = False + if tension_test_sensor is not None: + self.mmu.log_info("Setting tension sensor to 'detected'") + tension_sensor_filament_present = True + elif sync_state in ['both', 'neutral']: + if compression_test_sensor is not None: + self.mmu.log_info("Setting compression sensor to 'detected'") + compression_sensor_filament_present = True + if tension_test_sensor is not None: + self.mmu.log_info("Setting tension sensor to 'detected'") + tension_sensor_filament_present = True + else: + self.mmu.log_error("Invalid sync state: %s" % sync_state) + # Generate a tension or compression event + self.mmu.log_trace(">>>>>> sync test Testing configuration %s" % (sync_state.upper())) + if compression_test_sensor is not None: + compression_test_sensor.runout_helper.note_filament_present(compression_sensor_filament_present) + if tension_test_sensor is not None: + tension_test_sensor.runout_helper.note_filament_present(tension_sensor_filament_present) + # Remove event handlers + self.mmu.printer.event_handlers.pop("mmu:sync_feedback_finished", None) + self.mmu.printer.event_handlers.pop("mmu:test_gen_finished", None) + return + + + feedback = gcmd.get_float('SYNC_EVENT', None, minval=-1., maxval=1.) + if feedback is not None: + have_run_test = True + self.mmu.log_info("Sending 'mmu:sync_feedback %.2f' event" % feedback) + self.mmu.printer.send_event("mmu:sync_feedback", self.mmu.toolhead.get_last_move_time(), feedback) + + + if gcmd.get_int('DUMP_UNICODE', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_info("UI_SPACE=%s, UI_SEPARATOR=%s, UI_DASH=%s, UI_DEGREE=%s, UI_BLOCK=%s, UI_CASCADE=%s" % (UI_SPACE, UI_SEPARATOR, UI_DASH, UI_DEGREE, UI_BLOCK, UI_CASCADE)) + self.mmu.log_info("{}{}{}{}".format(UI_BOX_TL, UI_BOX_T, UI_BOX_H, UI_BOX_TR)) + self.mmu.log_info("{}{}{}{}".format(UI_BOX_L, UI_BOX_M, UI_BOX_H, UI_BOX_R)) + self.mmu.log_info("{}{}{}{}".format(UI_BOX_V, UI_BOX_V, UI_SPACE, UI_BOX_V)) + self.mmu.log_info("{}{}{}{}".format(UI_BOX_BL, UI_BOX_B, UI_BOX_H, UI_BOX_BR)) + self.mmu.log_info("UI_EMOTICONS=%s" % UI_EMOTICONS) + + + if gcmd.get_int('RUN_SEQUENCE', 0, minval=0, maxval=1): + have_run_test = True + error = gcmd.get_int('ERROR', 0, minval=0, maxval=1) + if gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1): + self.mmu._set_print_state("printing") + with self.mmu._wrap_track_time('total'): + with self.mmu._wrap_track_time('unload'): + with self.mmu._wrap_track_time('pre_unload'): + self.mmu.wrap_gcode_command(self.mmu.pre_unload_macro, exception=False, wait=True) + self.mmu.wrap_gcode_command(self.mmu.post_form_tip_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('post_unload'): + self.mmu.wrap_gcode_command(self.mmu.post_unload_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('load'): + with self.mmu._wrap_track_time('pre_load'): + self.mmu.wrap_gcode_command(self.mmu.pre_load_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('post_load'): + self.mmu.wrap_gcode_command(self.mmu.post_load_macro, exception=False, wait=False) + if error: + self.mmu.wrap_gcode_command("MMU_PAUSE") + self.mmu.log_info("Statistics:%s" % self.mmu.last_statistics) + self.mmu._set_print_state("idle") + + + if gcmd.get_int('RUN_CHANGE_SEQUENCE', 0, minval=0, maxval=1): + have_run_test = True + pause = gcmd.get_int('PAUSE', 0, minval=0, maxval=1) + next_pos = gcmd.get('NEXT_POS', "last") + goto_pos = None + if next_pos == 'next': + self.mmu.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=_MMU_SEQUENCE_VARS VARIABLE=restore_xy_pos VALUE='\"%s\"'" % next_pos) + goto_pos = [11, 11] + self.mmu._set_next_position(goto_pos) + if gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1): + self.mmu._set_print_state("printing") + self.mmu._save_toolhead_position_and_park('toolchange', next_pos=goto_pos) + with self.mmu._wrap_track_time('total'): + try: + with self.mmu._wrap_track_time('unload'): + with self.mmu._wrap_track_time('pre_unload'): + self.mmu.wrap_gcode_command(self.mmu.pre_unload_macro, exception=False, wait=True) + self.mmu.wrap_gcode_command(self.mmu.post_form_tip_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('post_unload'): + self.mmu.wrap_gcode_command(self.mmu.post_unload_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('load'): + with self.mmu._wrap_track_time('pre_load'): + self.mmu.wrap_gcode_command(self.mmu.pre_load_macro, exception=False, wait=True) + if pause: + raise MmuError("TEST ERROR") + else: + with self.mmu._wrap_track_time('post_load'): + self.mmu.wrap_gcode_command(self.mmu.post_load_macro, exception=False, wait=True) + self.mmu._restore_toolhead_position('toolchange') + except MmuError as ee: + self.mmu.handle_mmu_error(str(ee)) + self.mmu.log_info("Statistics:%s" % self.mmu.last_statistics) + self.mmu._set_print_state("idle") + + + if gcmd.get_int('SYNC_G2E', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.mmu_toolhead.sync(MmuToolHead.GEAR_SYNCED_TO_EXTRUDER) + + + if gcmd.get_int('SYNC_E2G', 0, minval=0, maxval=1): + have_run_test = True + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0, minval=0, maxval=1)) + self.mmu.mmu_toolhead.sync(MmuToolHead.EXTRUDER_ONLY_ON_GEAR if extruder_only else MmuToolHead.EXTRUDER_SYNCED_TO_GEAR) + + + if gcmd.get_int('UNSYNC', 0, minval=0, maxval=1): + have_run_test = True + gear_only = bool(gcmd.get_int('GEAR_ONLY', 0, minval=0, maxval=1)) + self.mmu.mmu_toolhead.sync(MmuToolHead.GEAR_ONLY if gear_only else None) + + + pos = gcmd.get_float('SET_POS', -1, minval=0, maxval=10) + if pos >= 0: + have_run_test = True + self.mmu._set_filament_pos_state(pos) + + + rd = gcmd.get_float('SET_RD', None, above=0) + if rd is not None: + have_run_test = True + gate = gcmd.get_int('GATE', -1, minval=-2, maxval=self.mmu.num_gates) + if gate >= 0: + self.mmu.calibration_manager.update_gear_rd(rd, gate) + + + position = gcmd.get_float('SET_POSITION', -1, minval=0) + if position >= 0: + have_run_test = True + self.mmu._set_filament_position(position) + + + if gcmd.get_int('GET_POSITION', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_info("Filament position: %s" % self.mmu._get_filament_position()) + + + action = gcmd.get_float('SET_ACTION', -1, minval=0) + if action >= 0: + have_run_test = True + self.mmu.action = action + + + if gcmd.get_int('QUIESCE_TEST', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_warning( + "Setup:\n" + "toolhead sensor should be present or dummy ones created\n" + "Extruder should be hot (able to extrude)" + ) + + # Simple dispatcher so the sequence below stays clean and declarative + def _exec(ops): + for i, (op, arg) in enumerate(ops, start=1): + if op == "g": + self.mmu.log_info("Step %d: %s" % (i, arg)) + self.mmu.gcode.run_script_from_command(arg) + elif op == "sync": + self.mmu.log_info("Step %d: %s" % (i, arg)) + self.mmu.mmu_toolhead.sync(arg) + elif op == "unsync": + self.mmu.log_info("Step %d: unsync()" % i) + self.mmu.mmu_toolhead.unsync() + else: + self.mmu.log_warning("Step %d: unknown op %s" % (i, op)) + + ops = [ + ("unsync", None), + ("g", f"G1 E-24 F6000"), + ("sync", MmuToolHead.GEAR_SYNCED_TO_EXTRUDER), + ("g", f"G1 E8 F6000"), + ("unsync", None), + ("g", f"G1 E2 F6000"), + ("g", "MMU_TEST_HOMING_MOVE MOTOR=gear MOVE=30 ENDSTOP=toolhead STOP_ON_ENDSTOP=1"), + ("g", f"G1 E-24 F6000"), + ("sync", MmuToolHead.EXTRUDER_SYNCED_TO_GEAR), + ("g", f"G1 E-2 F6000"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=1"), + ("g", "MMU_TEST_HOMING_MOVE MOTOR=gear+extruder MOVE=30 ENDSTOP=toolhead STOP_ON_ENDSTOP=1"), + ("g", "MMU_TEST_MOVE MOTOR=gear+extruder MOVE=30"), + ("g", f"G1 E8 F6000"), + ("unsync", None), + ("g", f"G1 E-10 F6000"), + ("g", f"G1 E8 F6000"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", f"G1 E-10 F6000"), + ("g", f"G1 E8 F6000"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", f"G1 E-10 F6000"), + ("g", f"G1 E8 F6000"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", f"G1 E-10 F6000"), + ] + _exec(ops) + + # Test of all expected MMU and extruder movement (this must be bulletproof) + # e.g. _MMU_TEST REALISTIC_SYNC_TEST=1 ENDSTOP=toolhead LOOP=100 SELECT=1 SERVO=1 + if gcmd.get_int('REALISTIC_SYNC_TEST', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_warning("Setup:\ntoolhead sensor should be present or dummy ones created or use ENDSTOP=xxx\nExtruder should be hot (able to extrude)\nSELECT=1 turns on gate selection on type-B designs") + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + select = gcmd.get_int('SELECT', 0, minval=0, maxval=1) + servo = gcmd.get_int('SERVO', 0, minval=0, maxval=1) + endstop = gcmd.get('ENDSTOP', "toolhead") + + try: + if servo: + self.mmu._is_running_test = False # Else servo won't move + + for i in range(loop): + log("Loop: %d..." % i) + + # Run a few randomized moves on the mmu toolhead to simulate load/unload logic (optional gate switch) + tracked = 0. + initial_pos = self.mmu._get_filament_position() + for j in range(6): + move_type = random.randint(0, 10) # 11 to enable tracking test + move = random.randint(0, 100) - 50 + speed = random.uniform(50, 200) + accel = random.randint(50, 1000) + homing_move = random.randint(-1, 1) + motor = random.choice(["gear", "gear+extruder", "extruder"]) + wait = random.randint(0, 1) + ed = random.randint(0, 4) + encoder_dwell = True if ed == 0 else None if ed == 1 else False + + # Sometimes switch gate. This will test interleaved selector movement / gear rail reconfiguration + if select and random.randint(0, 3) == 0: + gate = random.randint(0, self.mmu.num_gates - 1) + log("Selecting gate: %d" % gate) + self.mmu.select_gate(gate) + + # Sometimes inject servo movement for selectors with servo + if servo and random.randint(0, 4) == 0: + pos = "move" if random.randint(0, 1) else "down" + log("Moving servo to position: %s" % pos) + self.mmu.gcode.run_script_from_command("MMU_SERVO POS=%s" % pos) + + log("> Internal mmu movement %d: move(%s, motor=%s, speed=%.2f, accel=%s, homing_move=%s, endstop_name=%s, encoder_dwell=%s, wait=%s)" % (j, move, motor, speed, accel, homing_move, endstop, encoder_dwell, wait)) + actual,_,_,_ = self.mmu.trace_filament_move("REALISTIC_SYNC_TEST", move, motor=motor, speed=speed, accel=accel, homing_move=homing_move, endstop_name=endstop, encoder_dwell=encoder_dwell, speed_override=False, wait=wait) + tracked += actual + + # Check MMU position is correct + final_pos = self.mmu._get_filament_position() + expected = final_pos - initial_pos + if not math.isclose(expected, tracked): + raise MmuError("TEST ERROR: inital_pos=%.6f, final_pos=%.6f, tracked=%.6f (expected=%.6f)" % (initial_pos, final_pos, tracked, expected)) + + # Run a few randomized moves on the printer toolhead to simulate user movement + # Sync state must either be unsynced or GEAR_SYNCED_TO_EXTRUDER + sync = None if random.randint(0, 1) else MmuToolHead.GEAR_SYNCED_TO_EXTRUDER + self.mmu.mmu_toolhead.sync(sync) + log(">> Extruder movement (%s)..." % ("not synced" if sync is None else "synced to extruder")) + for j in range(5): + move = random.randint(-10, 10) + self.mmu.gcode.run_script_from_command("G1 E%d F6000" % move) + + # Simulate a call back into _MMU_STEP_MOVE or similar call - e.g. user calls from post_load_macro + if random.randint(0, 4) == 0: + log(">>> Calling _MMU_STEP_** move") + if random.randint(0, 1): + self.mmu.gcode.run_script_from_command("_MMU_STEP_MOVE MOVE=10") + else: + self.mmu.gcode.run_script_from_command("_MMU_STEP_HOMING_MOVE MOVE=10 ENDSTOP=%s" % endstop) + + log("TEST COMPLETE") + + except MmuError as ee: + log("TEST TERMINATED WITH MMU EXCEPTION: %s" % str(ee)) + + + if gcmd.get_int('SYNC_LOAD_TEST', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_warning("Setup:\ntoolhead sensor should be present or dummy ones created or use ENDSTOP=xxx\nExtruder should be hot (able to extrude)\nSELECT=0 turns off gate selection on type-B designs\nWAIT=[0|1] forces wait on movequeues condition after non-homing moves") + endstop = gcmd.get('ENDSTOP', 'toolhead') + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + select = gcmd.get_int('SELECT', 1, minval=0, maxval=1) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + self.mmu.gcode.run_script_from_command("SAVE_GCODE_STATE NAME=mmu_test") + self.mmu._initialize_filament_position() + total = 0. + for i in range(loop): + move_type = random.randint(0, 10) # 11 to enable tracking test + move = random.randint(0, 100) - 50 + speed = random.uniform(50, 200) + accel = random.randint(50, 1000) + homing = random.randint(0, 1) + extruder_only = random.randint(0, 1) + motor = random.choice(["gear", "gear+extruder", "extruder"]) + w = random.randint(0, 1) if wait is None else wait + if select and self.mmu.mmu_machine.multigear: + if random.randint(0, 1): + gate = random.randint(0, self.mmu.num_gates - 1) + log("Selecting gate: %d" % gate) + self.mmu.select_gate(gate) + if move_type in (0, 1): + log("Loop: %d - Synced extruder movement with G1 Ex: %.1fmm" % (i, move)) + self.mmu.mmu_toolhead.sync(MmuToolHead.GEAR_SYNCED_TO_EXTRUDER) + self.mmu.gcode.run_script_from_command("G1 E%.2f F%d" % (move, speed * 60)) + elif move_type == 2: + log("Loop: %d - Unsynced extruder movement with G1 Ex: %.1fmm" % (i, move)) + self.mmu.mmu_toolhead.unsync() + self.mmu.gcode.run_script_from_command("G1 E%.2f F%d" % (move, speed * 60)) + elif move_type == 3: + log("Loop: %d - Regular mmu move: %.1fmm, MOTOR=%s" % (i, move, motor)) + self.mmu.gcode.run_script_from_command("MMU_TEST_MOVE MOTOR=%s MOVE=%.2f SPEED=%d WAIT=%d" % (motor, move, speed, w)) + total += move + elif move_type in (4, 5, 6): + log("Loop: %d - HOMING MOVE: %.1fmm, MOTOR=%s" % (i, move, motor)) + self.mmu.gcode.run_script_from_command("MMU_TEST_HOMING_MOVE MOTOR=%s MOVE=%.2f SPEED=%d ENDSTOP=%s STOP_ON_ENDSTOP=1" % (motor, move, speed, endstop)) + total += move + elif move_type == 7: + if random.randint(0, 1): + new_pos = random.uniform(0, 300) + log("Loop: %d - Set filament position" % i) + self.mmu._set_filament_position(new_pos) + total = new_pos + else: + log("Loop: %d - Initialized filament position" % i) + self.mmu._initialize_filament_position() + total = 0. + elif move_type == 8: + log("Loop: %d - Save gcode state" % i) + self.mmu.gcode.run_script_from_command("SAVE_GCODE_STATE NAME=mmu_test") + elif move_type == 9: + log("Loop: %d - Restore gcode state" % i) + self.mmu.gcode.run_script_from_command("RESTORE_GCODE_STATE NAME=mmu_test") + elif move_type == 10: + log("Loop: %d - Synced extruder movement: %.1fmm" % (i, move)) + self.mmu.gcode.run_script_from_command("MMU_TEST_MOVE MOTOR=synced MOVE=%.2f SPEED=%d WAIT=%d" % (move, speed, w)) + else: + sync = "---" if self.mmu.mmu_toolhead.sync_mode is None else "E2G" if self.mmu.mmu_toolhead.sync_mode == MmuToolHead.EXTRUDER_SYNCED_TO_GEAR else "G2E" if self.mmu.mmu_toolhead.sync_mode == MmuToolHead.GEAR_SYNCED_TO_EXTRUDER else "Ext" + self.mmu.movequeues_wait() + tracking = abs(self.mmu._get_filament_position() - total) < 0.1 + self.mmu.log_info(">>>>>> STATUS: sync: %s, pos=%.2f, total=%.2f" % (sync, self.mmu._get_filament_position(), total)) + if not tracking: + self.mmu.log_error(">>>>>> Position tracking error") + break + self.mmu.gcode.run_script_from_command("_MMU_DUMP_TOOLHEAD") + self.mmu.log_info("Aggregate move distance: %.1fmm, Toolhead reports: %.1fmm" % (total, self.mmu._get_filament_position())) + + + if gcmd.get_int('SEL_MOVE', 0, minval=0, maxval=1): + have_run_test = True + move = gcmd.get_float('MOVE', 10.) + speed = gcmd.get_float('SPEED', None) + accel = gcmd.get_float('ACCEL', None) + wait = gcmd.get_int('WAIT', 1, minval=0, maxval=1) + loop = gcmd.get_int('LOOP', 1, minval=1) + for i in range(loop): + pos = self.mmu.mmu_toolhead.get_position()[0] + actual = self.mmu.selector.move("Test move", pos + move, speed=speed, accel=accel, wait=wait) + self.mmu.log_always("%d. Rail starting pos: %s, Selector moved to %.4fmm" % (i, pos, actual)) + if actual != pos + move: + self.mmu.log_always("Off target position by: %.4f" % (actual - (pos + move))) + + + if gcmd.get_int('SEL_HOMING_MOVE', 0, minval=0, maxval=1): + have_run_test = True + move = gcmd.get_float('MOVE', 10.) + speed = gcmd.get_float('SPEED', None) + accel = gcmd.get_float('ACCEL', None) + loop = gcmd.get_int('LOOP', 1, minval=1) + endstop = gcmd.get('ENDSTOP', self.mmu.SENSOR_SELECTOR_TOUCH if self.mmu.selector.use_touch_move() else self.mmu.SENSOR_SELECTOR_HOME) + for i in range(loop): + pos = self.mmu.mmu_toolhead.get_position()[0] + self.mmu.log_always("Rail starting pos: %s" % pos) + actual,homed = self.mmu.selector.homing_move("Test homing move", pos + move, speed=speed, accel=accel, homing_move=1, endstop_name=endstop) + self.mmu.log_always("%d. Rail starting pos: %s, Selector moved to %.4fmm homing to %s (%s)" % (i, pos, actual, endstop, "homed" if homed else "DID NOT HOME")) + if actual != pos + move: + self.mmu.log_always("Off target position by: %.4f" % (actual - (pos + move))) + + + if gcmd.get_int('SEL_LOAD_TEST', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + if gcmd.get_int('HOME', 0, minval=0, maxval=1): + self.mmu.gcode.run_script_from_command("MMU_HOME") + for i in range(loop): + move_type = random.randint(0, 2) + move = random.randint(10, 100) + speed = random.uniform(50, 200) + accel = random.randint(50, 2000) + homing = random.randint(0, 2) + wait = random.randint(0, 1) + pos = self.mmu.mmu_toolhead.get_position()[0] + if move_type in (1, 2): + endstop = "mmu_sel_touch" if move_type == 2 else "mmu_sel_home" + actual,homed = self.mmu.selector.homing_move("Test homing move", move, speed=speed, accel=accel, homing_move=1, endstop_name=endstop) + self.mmu.log_always("%d. Homing move: Rail starting pos: %s, Selector moved to %.4fmm homing to %s (%s)" % (i, pos, actual, endstop, "homed" if homed else "DID NOT HOME")) + else: + actual = self.mmu.selector.move("Test move", move, speed=speed, accel=accel, wait=w) + self.mmu.log_always("%d. Move: Rail starting pos: %s, Selector moved to %.4fmm" % (i, pos, actual)) + + + if gcmd.get_int('TTC_TEST', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + debug = gcmd.get_int('DEBUG', 0, minval=0, maxval=1) + mix = gcmd.get_int('MIX', 0, minval=0, maxval=1) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + for i in range(loop): + self.mmu.log_info("Loop: %d" % i) + w = random.randint(0, 1) if wait is None else wait + if self.mmu.mmu_machine.multigear: + self.mmu.select_gate(random.randint(0, self.mmu.num_gates - 1)) + stop_on_endstop = random.choice([-1, 1]) + motor = "gear+extruder" if random.randint(0, mix) else "extruder" + self.mmu.gcode.run_script_from_command("MMU_TEST_HOMING_MOVE MOTOR=%s MOVE=5 ENDSTOP=toolhead STOP_ON_ENDSTOP=%d DEBUG=%d" % (motor, stop_on_endstop, debug)) + if random.randint(0, 1): + self.mmu.gcode.run_script_from_command("MMU_TEST_MOVE MOTOR=%s MOVE=5 DEBUG=%d WAIT=%d" % (motor, debug, w)) + if random.randint(0, 1): + self.mmu.mmu_toolhead.get_last_move_time() # Try to provoke TTC + + + if gcmd.get_int('TTC_TEST2', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + debug = gcmd.get_int('DEBUG', 0, minval=0, maxval=1) + mix = gcmd.get_int('MIX', 0, minval=0, maxval=1) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + for i in range(loop): + stop_on_endstop = random.choice([-1, 0, 1]) + w = random.randint(0, 1) if wait is None else wait + self.mmu.log_info("Loop: %d" % i) + motor = "gear+extruder" if random.randint(0, mix) else "extruder" + self.mmu.trace_filament_move("test", 5, motor=motor, homing_move=stop_on_endstop, endstop_name="toolhead", wait=w) + if random.randint(0, 1): + self.mmu.gcode.run_script_from_command("M83") + self.mmu.gcode.run_script_from_command("G1 E5 F300") + + + if gcmd.get_int('TTC_TEST3', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + debug = gcmd.get_int('DEBUG', 0, minval=0, maxval=1) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + for i in range(loop): + self.mmu.log_info("Loop: %d" % i) + w = random.randint(0, 1) if wait is None else wait + if self.mmu.mmu_machine.multigear: + self.mmu.select_gate(random.randint(0, self.mmu.num_gates - 1)) + stop_on_endstop = random.choice([-1, 1]) + motor = "gear" + self.mmu.gcode.run_script_from_command("MMU_TEST_HOMING_MOVE MOTOR=%s MOVE=-70 SPEED=300 ACCEL=1000 ENDSTOP=toolhead STOP_ON_ENDSTOP=%d DEBUG=%d" % (motor, stop_on_endstop, debug)) + if random.randint(0, 1): + self.mmu.gcode.run_script_from_command("MMU_TEST_MOVE MOTOR=%s MOVE=5 DEBUG=%d WAIT=%d" % (motor, debug, w)) + if random.randint(0, 1): + self.mmu.mmu_toolhead.get_last_move_time() # Try to provoke TTC + + + if gcmd.get_int('STEPCOMPRESS_TEST', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 1, minval=10, maxval=1000) + debug = gcmd.get_int('DEBUG', 0, minval=0, maxval=1) + motor = gcmd.get('MOTOR', None) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + select = gcmd.get_int('SELECT', 1, minval=0, maxval=1) + stop_on_endstop = gcmd.get_int('STOP_ON_ENDSTOP', None, minval=-1, maxval=1) + for i in range(loop): + self.mmu.log_info("Loop: %d" % i) + w = random.randint(0, 1) if wait is None else wait + if self.mmu.mmu_machine.multigear and select: + self.mmu.select_gate(random.randint(0, self.mmu.num_gates - 1)) + self.mmu.gcode.run_script_from_command("M83") + self.mmu.gcode.run_script_from_command("G1 E1 F300") + if motor is None: + motor = "gear+extruder" if random.randint(0, 1) else "extruder" + if stop_on_endstop is None: + stop_on_endstop = random.choice([-1, 0, 1]) + if wait is None: + w = random.randint(0, 1) + with DebugStepperMovement(self.mmu, debug): + self.mmu.trace_filament_move("test", 1, motor=motor, homing_move=stop_on_endstop, endstop_name="toolhead", wait=w) + + + if gcmd.get_int('AUTO_CALIBRATE', 0, minval=0, maxval=1): + have_run_test = True + gate = gcmd.get_int('GATE', 0, minval=-2, maxval=8) + direction = gcmd.get_int('DIRECTION', 1, minval=-1, maxval=1) + ratio = gcmd.get_float('RATIO', 1., minval=-1, maxval=2) + homing_movement = gcmd.get_float('HOMING', None, minval=0, maxval=100) + self.mmu.gate_selected = gate + self.mmu._auto_calibrate(direction, ratio, homing_movement) + + + select_gate = gcmd.get_int('GATE_MOTOR', -99, minval=self.mmu.TOOL_GATE_BYPASS, maxval=self.mmu.num_gates) + if not select_gate == -99: + have_run_test = True + self.mmu.mmu_toolhead.select_gear_stepper(select_gate) + + + if gcmd.get_int('CALC_PURGE', 0, minval=0, maxval=1): + have_run_test = True + purge_vol_calc = PurgeVolCalculator(0, 800, 1.0) + + purge_vol = purge_vol_calc.calc_purge_vol_by_rgb(192, 192, 192, 247, 35, 35) + self.mmu.log_always("The purge vol from RGB color 192, 192, 192 to 247, 35, 35 is: {}".format(purge_vol)) + + purge_vol = purge_vol_calc.calc_purge_vol_by_hex("#C0C0C0", "#F72323") + self.mmu.log_always("The purge vol from hex color #C0C0C0 to #F72323 is: {}".format(purge_vol)) + + tool_colors = ["FFFF00","80FFFF","FFFFFF","FF8000"] + purge_volumes = self.mmu._generate_purge_matrix(tool_colors, 0, 800, 1.0) + self.mmu.log_always("\ntool_colors={}\npurge_volumes={}".format(tool_colors, purge_volumes)) + purge_volumes = self.mmu._generate_purge_matrix(tool_colors, 0, 800, 0.5) + self.mmu.log_always("\ntool_colors={}\npurge_volumes={}".format(tool_colors, purge_volumes)) + + + runout = gcmd.get_int('RUNOUT', None, minval=0, maxval=1) + if runout is not None: + have_run_test = True + if runout == 1: + self.mmu._enable_runout() + elif runout == 0: + self.mmu._disable_runout() + + + if gcmd.get_int('SENSOR', 0, minval=0, maxval=1): + have_run_test = True + pos = gcmd.get_int('POS', 0, minval=-1) + gate = gcmd.get_int('GATE', 0, minval=-2, maxval=8) + loading = bool(gcmd.get_int('LOADING', 1, minval=0, maxval=1)) + loop = gcmd.get_int('LOOP', 0, minval=0, maxval=1) + + if not loop: + sensors = self.mmu.sensor_manager.get_sensors_before(pos, gate, loading=loading) + self.mmu.log_always("check_all_sensors_before(%s,%s)=%s" % (pos, gate, self.mmu.sensor_manager.check_all_sensors_before(pos, gate, loading=loading))) + self.mmu.log_always("sensors before=%s" % sensors) + + sensors = self.mmu.sensor_manager.get_sensors_after(pos, gate, loading=loading) + self.mmu.log_always("check_all_sensors_after(%s,%s)=%s" % (pos, gate, self.mmu.sensor_manager.check_all_sensors_after(pos, gate, loading=loading))) + self.mmu.log_always("sensors after=%s" % sensors) + else: + for pos in range(-1,11): + self.mmu.log_always("check_all_sensors_before(%s,%s)=%s" % (pos, gate, self.mmu.sensor_manager.check_all_sensors_before(pos, gate, loading=loading))) + self.mmu.log_always("") + for pos in range(-1,11): + self.mmu.log_always("check_all_sensors_after(%s,%s)=%s" % (pos, gate, self.mmu.sensor_manager.check_all_sensors_after(pos, gate, loading=loading))) + self.mmu.log_always("check_any_sensors_in_path()=%s" % self.mmu.sensor_manager.check_any_sensors_in_path()) + self.mmu.log_always("check_for_runout()=%s" % self.mmu.sensor_manager.check_for_runout()) + + + fil_pos = gcmd.get_int('FILAMENT_POS', -2, minval=-1, maxval=10) + if fil_pos != -2: + have_run_test = True + with self.mmu.wrap_sync_gear_to_extruder(): + self.mmu._set_filament_pos_state(fil_pos) + + if not have_run_test: + self.mmu.log_warning("Not a valid test. Use HELP=1 for tests") + + finally: + self.mmu._is_running_test = False # Restore non testing context + diff --git a/extras/mmu/mmu_utils.py b/extras/mmu/mmu_utils.py new file mode 100644 index 000000000000..3075f549afe0 --- /dev/null +++ b/extras/mmu/mmu_utils.py @@ -0,0 +1,150 @@ +# Happy Hare MMU Software +# Utility classes for Happy Hare MMU +# +# DebugStepperMovement +# Goal: Internal testing class for debugging synced movement +# +# PurgeVolCalculator +# Goal: Purge volume calculator based on color change +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import math + + +# Internal testing class for debugging synced movement +# Add this around move logic: +# with DebugStepperMovement(self): +# +class DebugStepperMovement: + def __init__(self, mmu, debug=False): + self.mmu = mmu + self.debug = debug + + def __enter__(self): + if self.debug: + self.g_steps0 = self.mmu.gear_rail.steppers[0].get_mcu_position() + self.g_pos0 = self.mmu.gear_rail.steppers[0].get_commanded_position() + self.e_steps0 = self.mmu.mmu_extruder_stepper.stepper.get_mcu_position() + self.e_pos0 = self.mmu.mmu_extruder_stepper.stepper.get_commanded_position() + self.rail_pos0 = self.mmu.mmu_toolhead.get_position()[1] + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.debug: + self.mmu.log_always("Waiting for movement to complete...") + self.mmu.movequeues_wait() + g_steps1 = self.mmu.gear_rail.steppers[0].get_mcu_position() + g_pos1 = self.mmu.gear_rail.steppers[0].get_commanded_position() + e_steps1 = self.mmu.mmu_extruder_stepper.stepper.get_mcu_position() + e_pos1 = self.mmu.mmu_extruder_stepper.stepper.get_commanded_position() + rail_pos1 = self.mmu.mmu_toolhead.get_position()[1] + self.mmu.log_always("Gear steps: %d = %.4fmm, commanded movement: %.4fmm" % (g_steps1 - self.g_steps0, (g_steps1 - self.g_steps0) * self.mmu.gear_rail.steppers[0].get_step_dist(), g_pos1 - self.g_pos0)) + self.mmu.log_always("Extruder steps: %d = %.4fmm, commanded movement: %.4fmm" % (e_steps1 - self.e_steps0, (e_steps1 - self.e_steps0) * self.mmu.mmu_extruder_stepper.stepper.get_step_dist(), e_pos1 - self.e_pos0)) + self.mmu.log_always("Rail movement: %.4fmm" % (rail_pos1 - self.rail_pos0)) + + +class PurgeVolCalculator: + def __init__(self, min_purge_vol, max_purge_vol, multiplier): + self.min_purge_vol = min_purge_vol + self.max_purge_vol = max_purge_vol + self.multiplier = multiplier + + def calc_purge_vol_by_rgb(self, src_r, src_g, src_b, dst_r, dst_g, dst_b): + src_r_f = float(src_r) / 255.0 + src_g_f = float(src_g) / 255.0 + src_b_f = float(src_b) / 255.0 + dst_r_f = float(dst_r) / 255.0 + dst_g_f = float(dst_g) / 255.0 + dst_b_f = float(dst_b) / 255.0 + + from_hsv_h, from_hsv_s, from_hsv_v = self.RGB2HSV(src_r_f, src_g_f, src_b_f) + to_hsv_h, to_hsv_s, to_hsv_v = self.RGB2HSV(dst_r_f, dst_g_f, dst_b_f) + hs_dist = self.DeltaHS_BBS(from_hsv_h, from_hsv_s, from_hsv_v, to_hsv_h, to_hsv_s, to_hsv_v) + from_lumi = self.get_luminance(src_r_f, src_g_f, src_b_f) + to_lumi = self.get_luminance(dst_r_f, dst_g_f, dst_b_f) + + lumi_purge = 0.0 + if to_lumi >= from_lumi: + lumi_purge = math.pow(to_lumi - from_lumi, 0.7) * 560.0 + else: + lumi_purge = (from_lumi - to_lumi) * 80.0 + + inter_hsv_v = 0.67 * to_hsv_v + 0.33 * from_hsv_v + hs_dist = min(inter_hsv_v, hs_dist) + hs_purge = 230.0 * hs_dist + + purge_volume = self.calc_triangle_3rd_edge(hs_purge, lumi_purge, 120.0) + purge_volume = max(purge_volume, 0.0) + purge_volume += self.min_purge_vol + purge_volume *= self.multiplier + purge_volume = min(int(purge_volume), self.max_purge_vol) + + return purge_volume + + def calc_purge_vol_by_hex(self, src_clr, dst_clr): + src_rgb = self.hex_to_rgb(src_clr) + dst_rgb = self.hex_to_rgb(dst_clr) + return self.calc_purge_vol_by_rgb(*(src_rgb + dst_rgb)) + + @staticmethod + def RGB2HSV(r, g, b): + Cmax = max(r, g, b) + Cmin = min(r, g, b) + delta = Cmax - Cmin + + if abs(delta) < 0.001: + h = 0.0 + elif Cmax == r: + h = 60.0 * math.fmod((g - b) / delta, 6.0) + elif Cmax == g: + h = 60.0 * ((b - r) / delta + 2) + else: + h = 60.0 * ((r - g) / delta + 4) + s = 0.0 if abs(Cmax) < 0.001 else delta / Cmax + v = Cmax + return h, s, v + + @staticmethod + def to_radians(degree): + return degree / 180.0 * math.pi + + @staticmethod + def get_luminance(r, g, b): + return r * 0.3 + g * 0.59 + b * 0.11 + + @staticmethod + def calc_triangle_3rd_edge(edge_a, edge_b, degree_ab): + return math.sqrt(edge_a * edge_a + edge_b * edge_b - 2 * edge_a * edge_b * math.cos(PurgeVolCalculator.to_radians(degree_ab))) + + @staticmethod + def DeltaHS_BBS(h1, s1, v1, h2, s2, v2): + h1_rad = PurgeVolCalculator.to_radians(h1) + h2_rad = PurgeVolCalculator.to_radians(h2) + + dx = math.cos(h1_rad) * s1 * v1 - math.cos(h2_rad) * s2 * v2 + dy = math.sin(h1_rad) * s1 * v1 - math.sin(h2_rad) * s2 * v2 + dxy = math.sqrt(dx * dx + dy * dy) + + return min(1.2, dxy) + + @staticmethod + def hex_to_rgb(hex_color): + hex_color = hex_color.lstrip('#') + if len(hex_color) == 3: + hex_color = ''.join([c * 2 for c in hex_color]) + if len(hex_color) == 8: + hex_color = hex_color[:6] + if len(hex_color) != 6: + raise ValueError("Invalid hex color code, it should be 3, 6 or 8 digits long") + color_value = int(hex_color, 16) + r = (color_value >> 16) & 0xFF + g = (color_value >> 8) & 0xFF + b = color_value & 0xFF + return r, g, b diff --git a/extras/mmu_encoder.py b/extras/mmu_encoder.py new file mode 100644 index 000000000000..dded5a13d6a7 --- /dev/null +++ b/extras/mmu_encoder.py @@ -0,0 +1,310 @@ +# Happy Hare MMU Software +# Driver for encoder that supports movement measurement, runout/clog detection and flow rate calc +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Based on: +# Original Enraged Rabbit Carrot Feeder Project Copyright (C) 2021 Ette +# Generic Filament Sensor Module Copyright (C) 2019 Eric Callahan +# Filament Motion Sensor Module Copyright (C) 2021 Joshua Wherrett +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, time + +# Klipper imports +from . import pulse_counter + +class MmuEncoder: + CHECK_MOVEMENT_TIMEOUT = 0.250 + + RUNOUT_DISABLED = 0 + RUNOUT_STATIC = 1 + RUNOUT_AUTOMATIC = 2 + + def __init__(self, config): + self.name = config.get_name().split()[-1] + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object('gcode') + encoder_pin = config.get('encoder_pin') + self._logger = None + + # For counter functionality + self.sample_time = config.getfloat('sample_time', 0.1, above=0.) + self.poll_time = config.getfloat('poll_time', 0.001, above=0.) + self.set_resolution(config.getfloat('encoder_resolution', 1., above=0.)) # Must be calibrated by user in Happy Hare + self._last_time = None + self._counts = self._last_count = 0 + self._counter = pulse_counter.MCU_counter(self.printer, encoder_pin, self.sample_time, self.poll_time) + self._counter.setup_callback(self._counter_callback) + self._movement = False + + # For clog/runout functionality + self.extruder_name = config.get('extruder', 'extruder') + # The runout headroom that MMU will attempt to maintain (closest MMU comes to triggering runout) + self.desired_headroom = config.getfloat('desired_headroom', 6., above=0.) + # The "damping" effect of last measurement. Higher value means clog_length will be reduced more slowly + self.average_samples = config.getint('average_samples', 4, minval=1) + # The extrusion interval where new detection_length is calculated (also done on toolchange) + self.next_calibration_point = self.calibration_length = config.getfloat('calibration_length', 10000., minval=50.) # 10m + # Detection length will be set by MMU calibration + self.detection_length = self.min_headroom = config.getfloat('detection_length', 10., above=2.) # TODO this is now in flowguard! + self.event_delay = config.getfloat('event_delay', 2., above=0.) + self.pause_delay = config.getfloat('pause_delay', 0, above=0.) + self.runout_gcode = '__MMU_ENCODER_RUNOUT' + self.insert_gcode = '__MMU_ENCODER_INSERT' + self._enabled = True # Runout/Clog functionality + self.min_event_systime = self.reactor.NEVER + self.extruder = None + self.filament_detected = False + self.detection_mode = self.RUNOUT_STATIC + self.last_extruder_pos = self.filament_runout_pos = 0. + self.filament_runout_pos = self.min_headroom = self.detection_length + + # For flowrate functionality + self.flowrate_last_encoder_pos = 0. + self.extrusion_flowrate = 0. + self.samples = [] + self.flowrate_samples = config.getint('flowrate_samples', 20, minval=5) + + # Register event handlers + self.printer.register_event_handler('klippy:ready', self._handle_ready) + self.printer.register_event_handler('klippy:connect', self._handle_connect) + self.printer.register_event_handler('idle_timeout:printing', self._handle_printing) + self.printer.register_event_handler('idle_timeout:ready', self._handle_not_printing) + self.printer.register_event_handler('idle_timeout:idle', self._handle_not_printing) + + def _handle_connect(self): + try: + self.extruder = self.printer.lookup_object(self.extruder_name) + except Exception: + pass # Can set this later + + def _handle_ready(self): + self.min_event_systime = self.reactor.monotonic() + 2. # Don't process events too early + self._reset_filament_runout_params() + self._extruder_pos_update_timer = self.reactor.register_timer(self._extruder_pos_update_event) + + def _handle_printing(self, print_time): + self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NOW) # Enabled + + def _handle_not_printing(self, print_time): + self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NEVER) # Disabled + + def _get_extruder_pos(self, eventtime=None): + if eventtime is None: + eventtime = self.reactor.monotonic() + + print_time = self.printer.lookup_object('mcu').estimated_print_time(eventtime) + + if not self.extruder: + return 0. + + return self.extruder.find_past_position(print_time) + + # Called periodically to check filament movement + def _extruder_pos_update_event(self, eventtime): + if self._enabled: + extruder_pos = self._get_extruder_pos(eventtime) + + # First lets see if we got encoder movement since last invocation + if self._movement: + self._movement = False + self.filament_runout_pos = max(extruder_pos + self.detection_length, self.filament_runout_pos) + + if extruder_pos >= self.next_calibration_point: + if self.next_calibration_point > 0: + self._update_detection_length() + self.next_calibration_point = extruder_pos + self.calibration_length + if self.filament_runout_pos - extruder_pos < self.min_headroom: + self.min_headroom = self.filament_runout_pos - extruder_pos + if self._logger and self.min_headroom < self.desired_headroom: + if self.detection_mode == self.RUNOUT_AUTOMATIC: + self._logger("Automatic clog detection: new min_headroom (< %.1fmm desired): %.1fmm" % (self.desired_headroom, self.min_headroom)) + elif self.detection_mode == self.RUNOUT_STATIC: + self._logger("Warning: Only %.1fmm of headroom to clog/runout" % self.min_headroom) + self._handle_filament_event(extruder_pos < self.filament_runout_pos) + + # Flowrate calc. Depends of calibration accuracy of encoder + encoder_pos = self.get_distance() + # If encoder has moved, record the extruder and encoder movement for flow rate calcs + if encoder_pos > self.flowrate_last_encoder_pos: + self._record(encoder_pos, extruder_pos) + self.flowrate_last_encoder_pos = encoder_pos + + self.last_extruder_pos = extruder_pos + return eventtime + self.CHECK_MOVEMENT_TIMEOUT + + def _reset_filament_runout_params(self, eventtime=None): + if eventtime is None: + eventtime = self.reactor.monotonic() + self.last_extruder_pos = self._get_extruder_pos(eventtime) + self.flowrate_last_encoder_pos = self.get_distance() + self.extrusion_flowrate = 0. + self.samples = [] + self.filament_runout_pos = self.last_extruder_pos + self.detection_length + self.desired_headroom # Add headroom to decrease sensitivity on startup + self.next_calibration_point = self.last_extruder_pos + self.calibration_length + self.min_headroom = self.detection_length + + # Called periodically to tune the clog detection length + def _update_detection_length(self, increase_only=False): + if not self._enabled: return + if self.detection_mode != self.RUNOUT_AUTOMATIC: + return + current_detection_length = self.detection_length + if self.min_headroom < self.desired_headroom: + # Maintain headroom + extra_length = min((self.desired_headroom - self.min_headroom), self.desired_headroom) + self.detection_length += extra_length + if self._logger: + self._logger("Automatic clog detection: maintaining headroom by adding %.1fmm to detection_length" % extra_length) + elif not increase_only: + # Average down + sample = self.detection_length - (self.min_headroom - self.desired_headroom) + self.detection_length = ((self.average_samples * self.detection_length) + self.desired_headroom - self.min_headroom) / self.average_samples + if self._logger: + self._logger("Automatic clog detection: averaging down detection_length with new %.1fmm measurement" % sample) + else: + return + + self.min_headroom = self.detection_length + self.filament_runout_pos = self.last_extruder_pos + self.detection_length + if round(self.detection_length, 1) != round(current_detection_length, 1): # Persist if significant + if self._logger: + self._logger("Automatic clog detection: reset detection_length to %.1fmm" % self.min_headroom) + self.set_clog_detection_length(self.detection_length) + + # Called to see if state update requires callback notification + def _handle_filament_event(self, filament_detected): + if self.filament_detected == filament_detected: + return + self.filament_detected = filament_detected + eventtime = self.reactor.monotonic() + if eventtime < self.min_event_systime or self.detection_mode == self.RUNOUT_DISABLED or not self._enabled: + return + is_printing = self.printer.lookup_object("idle_timeout").get_status(eventtime)["state"] == "Printing" + if filament_detected: + if not is_printing and self.insert_gcode is not None: + # Insert detected + self.min_event_systime = self.reactor.NEVER + logging.info("MMU: Encoder Sensor %s: insert event detected, Time %.2f" % (self.name, eventtime)) + self.reactor.register_callback(self._insert_event_handler) + else: + if is_printing and self.runout_gcode is not None: + # Runout detected + self.min_event_systime = self.reactor.NEVER + logging.info("MMU: Encoder Sensor %s: runout event detected, Time %.2f" % (self.name, eventtime)) + self.reactor.register_callback(self._runout_event_handler) + + def _runout_event_handler(self, eventtime): + # Pausing from inside an event requires that the pause portion of pause_resume execute immediately. + pause_resume = self.printer.lookup_object('pause_resume') + pause_resume.send_pause_command() + if self.pause_delay: + self.printer.get_reactor().pause(eventtime + self.pause_delay) + self._exec_gcode(self.runout_gcode) + + def _insert_event_handler(self, eventtime): + self._exec_gcode(self.insert_gcode) + + def _exec_gcode(self, command): + try: + self.gcode.run_script(command) + except Exception: + logging.exception("MMU: Error running mmu encoder handler: `%s`" % command) + self.min_event_systime = self.reactor.monotonic() + self.event_delay + + def get_clog_detection_length(self): + return self.detection_length + + def set_clog_detection_length(self, clog_length): + self.detection_length = max(clog_length, 2.) + self._reset_filament_runout_params() + + def note_clog_detection_length(self): + self._update_detection_length() + + def set_mode(self, mode): + if self.RUNOUT_DISABLED <= mode <= self.RUNOUT_AUTOMATIC: + self.detection_mode = mode + + def set_extruder(self, extruder_name): + self.extruder = self.printer.lookup_object(extruder_name) + if not self.extruder: + raise self.printer.config.error("Extruder named `%s` not found" % extruder_name) + self.extruder_name = extruder_name + self.filament_runout_pos = self.min_headroom = self.detection_length + + def set_logger(self, log): + self._logger = log + + def enable(self): + self._reset_filament_runout_params() + self._enabled = True + + def disable(self): + self._enabled = False + + def is_enabled(self): + return self._enabled + + def _record(self, encoder_pos, extruder_pos): + self.samples.append((encoder_pos, extruder_pos)) + if len(self.samples) > self.flowrate_samples: + self.samples = self.samples[-self.flowrate_samples:] + encoder_movement = encoder_pos - self.samples[0][0] + extruder_movement = extruder_pos - self.samples[0][1] + new_extrusion_flowrate = (encoder_movement / extruder_movement) if extruder_movement > 0. else 1. + self.extrusion_flowrate = (self.extrusion_flowrate + new_extrusion_flowrate) / 2. + + # Callback for MCU_counter + def _counter_callback(self, print_time, count, count_time): + if self._last_time is None: # First sample + self._last_time = print_time + elif count_time > self._last_time: + self._last_time = count_time + new_counts = count - self._last_count + self._counts += new_counts + self._movement = new_counts > 0 + else: # No counts since last sample + self._last_time = print_time + self._last_count = count + + def set_resolution(self, resolution): + self.resolution = resolution + + def get_resolution(self): + return self.resolution + + def get_counts(self): + return self._counts + + def get_distance(self): + return self._counts * self.resolution + + def set_distance(self, new_distance): + self._counts = int(round(new_distance / self.resolution)) + + def reset_counts(self): + self._counts = 0 + + def get_status(self, eventtime): + return { + 'encoder_pos': round(self.get_distance(), 1), + 'detection_length': round(self.detection_length, 1), + 'min_headroom': round(self.min_headroom, 1), + 'headroom': round(self.filament_runout_pos - self.last_extruder_pos, 1), + 'desired_headroom': round(self.desired_headroom, 1), + 'detection_mode': self.detection_mode, + 'enabled': self._enabled, + 'flow_rate': int(round(min(self.extrusion_flowrate, 1.) * 100)) + } + +def load_config_prefix(config): + return MmuEncoder(config) diff --git a/extras/mmu_espooler.py b/extras/mmu_espooler.py new file mode 100644 index 000000000000..65d82c71ae24 --- /dev/null +++ b/extras/mmu_espooler.py @@ -0,0 +1,642 @@ +# Happy Hare MMU Software +# +# Implements h/w "eSpooler" control for a MMU unit that is powered by a DC motor +# (normally PWM speed controlled) that can be used to rewind a filament spool or be +# driven peridically in the forward direction to provide "forward assist" functionality. +# For simplicity of setup it is assumed that all pins are of the same type/config per mmu_unit. +# Control is via direct control or klipper events. +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, time + +from . import output_pin + +MAX_SCHEDULE_TIME = 5.0 + +class MmuESpooler: + + def __init__(self, config, first_gate=0, num_gates=23): + self.config = config + self.first_gate = first_gate + self.num_gates = num_gates + self.name = config.get_name().split()[-1] + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.mmu = None + self.respool_gates = [] # List of gates that can perform respool operation + self.assist_gates = [] # List of gates that can perform assist operation + self.burst_gates = {} # Key:Gate, Value:(operation, callback_timer) for gates currently executing a "burst" + + # The following implement the "burst assist". Currently only the print_assist_gate has burst_trigger_enabled + # but the orthogonal indicators would allow for future change in behavior + self.print_assist_gate = None # Current gate in "print assist" mode (should only be one or None) + self.burst_trigger_enabled = {} # Key: Gate, Value: True|False representing if trigger is enabled for each gate + self.burst_trigger_state = {} # Key: Gate, Value: 0|1 trigger button state for each gate trigger + self.back_to_back_burst_count = {} # Key: Gate, Value: Count of back-to-back bursts for each gate + + # Get config + self.motor_mcu_pins = {} # Key: pin_name, Value: mcu_pin + self.last_value = {} # Key: pin_name, Value: Last pwm value + self.operation = {} # Key: Gate, Value: (operation, pwm_value) tuple + ppins = self.printer.lookup_object('pins') + buttons = self.printer.load_object(config, 'buttons') + + # These params are assumed to be shared accross the espooler unit + self.is_pwm = config.getboolean("pwm", True) + self.hardware_pwm = config.getboolean("hardware_pwm", False) + self.scale = config.getfloat('scale', 1., above=0.) + self.cycle_time = config.getfloat("cycle_time", 0.100, above=0., maxval=MAX_SCHEDULE_TIME) + self.shutdown_value = config.getfloat('shutdown_value', 0., minval=0., maxval=self.scale) / self.scale + start_value = config.getfloat('value', 0., minval=0., maxval=self.scale) / self.scale + + for gate in range(self.first_gate, self.first_gate + self.num_gates + 1): + self.respool_motor_pin = config.get('respool_motor_pin_%d' % gate, None) + self.assist_motor_pin = config.get('assist_motor_pin_%d' % gate, None) + self.enable_motor_pin = config.get('enable_motor_pin_%d' % gate, None) + self.assist_trigger_pin = config.get('assist_trigger_pin_%d' % gate, None) + + valid_gate = False # This is hack to support HHv3 (remove in HHv4) + # Setup pins + if self.respool_motor_pin and not self._is_empty_pin(self.respool_motor_pin): + if self.is_pwm: + mcu_pin = ppins.setup_pin("pwm", self.respool_motor_pin) + mcu_pin.setup_cycle_time(self.cycle_time, self.hardware_pwm) + else: + mcu_pin = ppins.setup_pin("digital_out", self.respool_motor_pin) + + valid_gate = True + name = "respool_%d" % gate + mcu_pin.setup_max_duration(0.) + mcu_pin.setup_start_value(start_value, self.shutdown_value) + self.motor_mcu_pins[name] = mcu_pin + self.last_value[name] = start_value + self.respool_gates.append(gate) + + if self.assist_motor_pin and not self._is_empty_pin(self.assist_motor_pin): + if self.is_pwm: + mcu_pin = ppins.setup_pin("pwm", self.assist_motor_pin) + mcu_pin.setup_cycle_time(self.cycle_time, self.hardware_pwm) + else: + mcu_pin = ppins.setup_pin("digital_out", self.assist_motor_pin) + + valid_gate = True + name = "assist_%d" % gate + mcu_pin.setup_max_duration(0.) + mcu_pin.setup_start_value(start_value, self.shutdown_value) + self.motor_mcu_pins[name] = mcu_pin + self.last_value[name] = start_value + self.assist_gates.append(gate) + + if self.enable_motor_pin and not self._is_empty_pin(self.enable_motor_pin): + mcu_pin = ppins.setup_pin("digital_out", self.enable_motor_pin) + + valid_gate = True + name = "enable_%d" % gate + mcu_pin.setup_max_duration(0.) + mcu_pin.setup_start_value(self.last_value, self.shutdown_value) + self.motor_mcu_pins[name] = mcu_pin + self.last_value[name] = start_value + + if self.assist_trigger_pin and not self._is_empty_pin(self.assist_trigger_pin): + buttons.register_buttons( + [self.assist_trigger_pin], + lambda eventtime, state, gate=gate: self._handle_button_advance(eventtime, state, gate) + ) + + if valid_gate: + from .mmu import Mmu # For operation names + self.operation[gate] = (Mmu.ESPOOLER_OFF, 0) + self.back_to_back_burst_count[gate] = 0 + self.burst_trigger_state[gate] = 0 + else: + # Hack to support on HH v3 + self.num_gates = gate + 1 + break + + # Setup minimum number of gcode request queues + self.gcrqs = {} + for mcu_pin in self.motor_mcu_pins.values(): + mcu = mcu_pin.get_mcu() + # TODO Temporary workaround to allow Kalico to work since it lacks GCodeRequestQueue + if hasattr(output_pin, 'GCodeRequestQueue'): + self.gcrqs.setdefault(mcu, output_pin.GCodeRequestQueue(config, mcu, self._set_pin)) + else: + self.gcrqs.setdefault(mcu, GCodeRequestQueue(config, mcu, self._set_pin)) + + # Setup event handler for DC espooler motor burst operation + self.printer.register_event_handler("mmu:espooler_burst", self._handle_espooler_burst) + self.printer.register_event_handler("mmu:disabled", self._handle_mmu_disabled) + + # Register event handlers + self.printer.register_event_handler('klippy:ready', self._handle_ready) + + + def _handle_ready(self): + self.toolhead = self.printer.lookup_object('toolhead') + self.mmu = self.printer.lookup_object('mmu') + + # Setup extruder monitor + try: + self.extruder_monitor = self.ExtruderMonitor(self) + except Exception as e: + self.mmu.log_error(str(e)) + self.extruder_monitor = None + + + def _handle_mmu_disabled(self): + """ + Event indicating that the MMU unit was disabled. Make sure the espooler triggers are disabled + """ + self.reset_print_assist_mode() + + + def _valid_gate(self, gate): + return gate is not None and self.first_gate <= gate < self.first_gate + self.num_gates + + + def _is_empty_pin(self, pin): + if pin == '': return True + ppins = self.printer.lookup_object('pins') + pin_params = ppins.parse_pin(pin, can_invert=True, can_pullup=True) + pin_resolver = ppins.get_pin_resolver(pin_params['chip_name']) + real_pin = pin_resolver.aliases.get(pin_params['pin'], '_real_') + return real_pin == '' + + + def _handle_button_advance(self, eventtime, state, gate): + """ + Callback from button sensor to initiate burst assist + """ + self.mmu.log_trace("ESPOOLER: Trigger fired for gate %d, state=%s" % (gate, state)) + self.burst_trigger_state[gate] = state + if self.mmu and self.mmu.espooler_assist_burst_trigger and self.burst_trigger_enabled.get(gate, False): # Don't handle if not ready or disabled + if self.mmu.espooler_assist_burst_trigger and state: + self.back_to_back_burst_count[gate] += 1 + self.advance(gate) + else: + # Allow future triggers + self.back_to_back_burst_count[gate] = 0 + + + def _set_burst_trigger_enable(self, gate, enable): + from .mmu import Mmu # For operation names + + if self._valid_gate(gate): + cur_enabled = self.burst_trigger_enabled.get(gate, False) + if not cur_enabled and enable: + # Turn on and if currently triggered immediately advance + self.burst_trigger_enabled[gate] = True + if self.burst_trigger_state.get(gate, 0): + self.advance(gate) + elif cur_enabled and not enable: + self.burst_trigger_enabled[gate] = False + self.back_to_back_burst_count[gate] = 0 + # Turn off espooler can cancel any burst timer + self.set_operation(gate, 0, Mmu.ESPOOLER_OFF) + + + # Logic to handle a short "jog" rotation in assist or rewind direction ---------------------------------------------- + + def advance(self, gate=None): + """ + Direct call to initiate in print burst assist. + If called with gate=None then it is likely from the extruder monitor so apply to the + gate in "print assist" mode + """ + from .mmu import Mmu # For operation names + + if gate is None: + gate = self.print_assist_gate + + if gate is None: + self.mmu.log_trace("ESPOOLER: In print assist advance() called but no gate in 'print' mode (ignored)") + return + + self.burst(gate, Mmu.ESPOOLER_ASSIST) + + def burst(self, gate, operation): + """ + Direct call to rotate spool in a burst defined by operation (ESPOOLER_ASSIST or ESPOOLER_REWIND). + The burst will automatically be terminated after configured rotate parameters + (used in spool drying rotation, filament tightening and manual jogging) + """ + from .mmu import Mmu # For operation names + + if operation == Mmu.ESPOOLER_ASSIST: + power = self.mmu.espooler_assist_burst_power + duration = self.mmu.espooler_assist_burst_duration + elif operation == Mmu.ESPOOLER_REWIND: + power = self.mmu.espooler_rewind_burst_power + duration = self.mmu.espooler_rewind_burst_duration + else: + return + + self._handle_espooler_burst(gate, power / 100, duration, operation) + + + def _handle_espooler_burst(self, gate, value, duration, operation): + """ + Rotate burst: short jog movement of spool in selected direction + - Only allowed when gate is ESPOOLER_OFF or ESPOOLER_PRINT. + - While active for a gate, no other operations for that gate are allowed. + - Stops via scheduled callback at end of duration or manual ESPOOLER_OFF. + """ + from .mmu import Mmu # For operation names + + if not self._valid_gate(gate): + return + + # Per-gate lock: ignore if this gate is already in a rotation burst + if gate in self.burst_gates: + self.mmu.log_debug("Got espooler burst event for gate %d but burst already active (ignored)" % gate) + return + + if duration <= 0 or value == 0: + self.mmu.log_debug("Got bad espooler burst event for gate %d: duration=%.1f, value=%.1f (ignored)" % (gate, duration, value)) + return + + # Only allowed if not moving (OFF or PRINT) but always allow interuption of in-print assist gate + cur_op, cur_value = self.get_operation(gate) + msg = "ESPOOLER: Got espooler rotate event for gate %d: value=%.2f duration=%.1f" % (gate, value, duration) + if cur_op in [Mmu.ESPOOLER_OFF, Mmu.ESPOOLER_PRINT] or gate == self.print_assist_gate: + self.mmu.log_trace(msg) + + # Schedule future return to ESPOOLER_OFF / ESPOOLER_PRINT state + waketime = self.reactor.monotonic() + duration + timer = self.reactor.register_timer(lambda pt: self._stop_espooler_burst(gate=gate), waketime) + + # Take per-gate lock and start rewind motor + self.set_operation(gate, value, operation) + self.burst_gates[gate] = (operation, timer) + else: + msg += " (Ignored because espooler state is %s, value: %.2f)" % (cur_op, cur_value) + self.mmu.log_trace(msg) + + + def _stop_espooler_burst(self, gate): + """ + Scheduled (timer event) callback to terminate an espooler burst + """ + from .mmu import Mmu # For operation names + operation = Mmu.ESPOOLER_PRINT if gate == self.print_assist_gate else Mmu.ESPOOLER_OFF + if gate in self.burst_gates: + self.set_operation(gate, 0, operation) # This will call _dequeue_espooler_burst() + + # Monitor triggers for print assist gate + if gate == self.print_assist_gate: + if self.burst_trigger_state.get(gate, 0): + # Still triggered + if self.back_to_back_burst_count[gate] < self.mmu.espooler_assist_burst_trigger_max: + self.back_to_back_burst_count[gate] += 1 + self.advance(gate) + else: + self.mmu.log_error("Espooler assist temporarily suspended because of suspected malfunction. Assist trigger sensor may be stuck in triggered state") + else: + # Trigger has cleared, allow future triggers + self.back_to_back_burst_count[gate] = 0 + + return self.reactor.NEVER # This is setup as a one-shot timer (so early cancellation is possible) + + + def _dequeue_espooler_burst(self, gate): + """ + Cancel "espooler off" callback and remove from burst list. Send completion event + """ + # Cancel callback and remove from burst list + if gate in self.burst_gates: + timer = self.burst_gates[gate][1] + try: + self.reactor.unregister_timer(timer) + except Exception as e: + self.mmu.log_debug("Error cancelling burst callback: Exception: %s" % str(e)) + del self.burst_gates[gate] + + # Notify listeners + self.printer.send_event("mmu:espooler_burst_done", gate) + + + def set_print_assist_mode(self, gate): + """ + Efficient method to turn on in-print assist + """ + from .mmu import Mmu # For operation names + if self.print_assist_gate != gate: + self.set_operation(gate, 0, Mmu.ESPOOLER_PRINT) + + + def reset_print_assist_mode(self): + """ + Reset any gate in the sticky "in-print assist" mode. This is called a lot so should be efficient + """ + from .mmu import Mmu # For operation names + pg = self.print_assist_gate + if pg is not None: + self.print_assist_gate = None + self.mmu.log_trace("ESPOOLER: Cancelling in-print assist for gate %d" % pg) + self._update_pwm(pg, 0, Mmu.ESPOOLER_OFF) + self.operation[pg] = (Mmu.ESPOOLER_OFF, 0) + + # Disable all triggers + if self.mmu.espooler_assist_burst_trigger: + self._set_burst_trigger_enable(pg, False) + if self.extruder_monitor: + self.extruder_monitor.watch(False) + + self._dequeue_espooler_burst(pg) + + + # Change operation in progress and DC motor PWM control ------------------------------------------------------------- + + def set_operation(self, gate, value, operation): + """ + Direct call to change the operation of the espooler and adjust DC motor + Operations are: + ESPOOLER_OFF = Force motor off + ESPOOLER_REWIND = Set motor in rewind (retract) direction + ESPOOLER_ASSIST = Set motor in forward (assist) direction + ESPOOLER_PRINT = Set stick "in-print assist" mode and clear former gate in that mode + """ + from .mmu import Mmu # For operation names + + # To aid debugging... + if self.mmu.log_enabled(Mmu.LOG_TRACE): + self.mmu.log_trace("ESPOOLER: set_operation(gate=%s, value=%s, operation=%s)" % (gate, value, operation)) + + gates = [gate] + if gate is None: + gates = range(self.first_gate, self.first_gate + self.num_gates) + + for g in gates: + if not self._valid_gate(g): + self.mmu.log_trace("ESPOOLER: Trying to set Espooler operation of illegal gate %d (ignored)" % g) + continue + + cur_op, cur_value = self.get_operation(g) + + # OFF ---------------------------------------- + if operation == Mmu.ESPOOLER_OFF: + # Always update PWM as safety precaution + self._update_pwm(g, 0, Mmu.ESPOOLER_OFF) + + if g != self.print_assist_gate: + self.operation[g] = (Mmu.ESPOOLER_OFF, 0) + else: + self.operation[g] = (Mmu.ESPOOLER_PRINT, 0) + + if cur_op != Mmu.ESPOOLER_OFF: + self.mmu.log_debug("Espooler for gate %d turned off" % g) + + # Ensure any existing burst is canceled + self._dequeue_espooler_burst(g) + + # ASSIST or REWIND --------------------------- + elif operation in [Mmu.ESPOOLER_ASSIST, Mmu.ESPOOLER_REWIND]: + if cur_op not in [Mmu.ESPOOLER_OFF, Mmu.ESPOOLER_PRINT]: + # Stop PWM before sending new + self._update_pwm(g, 0, operation) + + conf_gates = self.assist_gates if operation == Mmu.ESPOOLER_ASSIST else self.respool_gates + if g in conf_gates: + self._update_pwm(g, value, operation) + self.operation[g] = (operation, value) + self.mmu.log_debug("Espooler for gate %d set to %s (pwm: %.2f)" % (g, operation, value)) + else: + self.mmu.log_debug("Espooler for gate %d not configured to perform %s operation" % (g, operation)) + + # Ensure any existing burst is canceled + self._dequeue_espooler_burst(g) + + # SPECIAL PRINT ASSIST MODE ------------------ + elif operation == Mmu.ESPOOLER_PRINT: + # Practically this will only be called on a single gate at a time + self.mmu.log_trace("ESPOOLER: Entering in-print assist mode for gate %d" % g) + + # Only one gate can be in print assist mode at a time so clear previous + if g != self.print_assist_gate and self.print_assist_gate is not None: + self.reset_print_assist_mode() + + # Only set sticky in-print trigger mode if pwm value is 0, else ignore + if value == 0: + self.print_assist_gate = g + + self._update_pwm(g, 0, operation) + self.operation[g] = (operation, 0) + + # Enable appropriate triggers + if self.mmu.espooler_assist_burst_trigger: + self._set_burst_trigger_enable(g, True) + elif self.extruder_monitor: + self.extruder_monitor.watch(True) + + self.mmu.log_debug("Espooler for gate %d set to %s (pwm: %.2f)" % (g, operation, value)) + + # Ensure any existing burst is canceled + self._dequeue_espooler_burst(g) + + + def get_operation(self, gate): + """ + Return tuple of current operation and pwm value for gate + """ + from .mmu import Mmu # For operation names + return self.operation.get(gate, (Mmu.ESPOOLER_OFF, 0)) + + + def _update_pwm(self, gate, value, operation): + """ + Set the PWM or digital signal for espooler on gate + The operation is used to assertain motor direction + """ + from .mmu import Mmu # For operation names + + if self.mmu.log_enabled(Mmu.LOG_STEPPER): + self.mmu.log_stepper("ESPOOLER: _update_pwm(%s, %s, %s)" % (gate, value, operation)) + + def _schedule_set_pin(name, value): + mcu_pin = self.motor_mcu_pins.get(name, None) + if mcu_pin: + estimated_print_time = mcu_pin.get_mcu().estimated_print_time(self.printer.reactor.monotonic()) + if self.mmu.log_enabled(Mmu.LOG_STEPPER): + self.mmu.log_stepper("ESPOOLER: --> _schedule_set_pin(name=%s, value=%s) @ print_time: %.8f" % (name, value, estimated_print_time)) + self.gcrqs[mcu_pin.get_mcu()].send_async_request((name, value)) + + # Sanity check + if operation == Mmu.ESPOOLER_OFF: + value = 0 + + # Clamp and scale value + value = max(0, min(1, value)) / self.scale + if not self.is_pwm: + value = 1 if value > 0 else 0 + + # Update PWM signal + if self.get_operation(gate) != (operation, value): + if value == 0: # Stop motor + _schedule_set_pin('enable_%d' % gate, 0) + _schedule_set_pin('respool_%d' % gate, 0) + _schedule_set_pin('assist_%d' % gate, 0) + else: + active_motor_name = 'respool_%d' % gate if operation == Mmu.ESPOOLER_REWIND else 'assist_%d' % gate + inactive_motor_name = 'assist_%d' % gate if operation == Mmu.ESPOOLER_REWIND else 'respool_%d' % gate + _schedule_set_pin(inactive_motor_name, 0) + _schedule_set_pin(active_motor_name, value) + _schedule_set_pin('enable_%d' % gate, 1) + + + # This is the actual callback method to update pin signal (pwm or digital) + def _set_pin(self, print_time, action): + from .mmu import Mmu # For operation names + + name, value = action + mcu_pin = self.motor_mcu_pins.get(name, None) + if mcu_pin: + if value == self.last_value.get(name, None): + return + if self.mmu.log_enabled(Mmu.LOG_STEPPER): + self.mmu.log_stepper("ESPOOLER: -----> _set_pin(name=%s, value=%s) @ print_time: %.8f" % (name, value, print_time)) + if self.is_pwm and not name.startswith('enable_'): + mcu_pin.set_pwm(print_time, value) + else: + mcu_pin.set_digital(print_time, value) + self.last_value[name] = value + + + # ------------------------------------------------------------------------------------------------------------------- + + def get_status(self, eventtime): + return { + 'espooler': [v[0] for v in self.operation.values()] + } + + + # Class to monitor extruder movement an generate espooler "advance" events + class ExtruderMonitor: + + CHECK_MOVEMENT_PERIOD = 1. # How often to check extruder movement + + def __init__(self, espooler): + self.espooler = espooler + self.reactor = espooler.reactor + self.estimated_print_time = espooler.printer.lookup_object('mcu').estimated_print_time + self.extruder = espooler.printer.lookup_object(espooler.mmu.extruder_name, None) + if not self.extruder: + raise espooler.config.error("Extruder named `%s` not found. Espooler extruder monitor disabled" % espooler.mmu.extruder_name) + + self.enabled = False + self.last_extruder_pos = None + self._extruder_pos_update_timer = self.reactor.register_timer(self._extruder_pos_update_event) + + def watch(self, enable): + if not self.enabled and enable: + # Ensure first burst after initial extruder movement + self.last_extruder_pos = self._get_extruder_pos() - self.espooler.mmu.espooler_assist_extruder_move_length + 1. + self.enabled = True + self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NOW) # Enabled + elif not enable: + self.last_extruder_pos = None + self.enabled = False + self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NEVER) # Disabled + + def _get_extruder_pos(self, eventtime=None): + if eventtime is None: + eventtime = self.reactor.monotonic() + print_time = self.estimated_print_time(eventtime) + if self.extruder: + pos = self.extruder.find_past_position(print_time) + return pos + else: + return 0. + + # Called periodically to check extruder movement + def _extruder_pos_update_event(self, eventtime): + extruder_pos = self._get_extruder_pos(eventtime) + #self.espooler.mmu.log_trace("ESPOOLER: current_extruder_pos: %s (last: %s)" % (extruder_pos, self.last_extruder_pos)) + if self.last_extruder_pos is not None and extruder_pos > self.last_extruder_pos + self.espooler.mmu.espooler_assist_extruder_move_length: + self.espooler.advance() # Initiate burst + self.last_extruder_pos = extruder_pos + return eventtime + self.CHECK_MOVEMENT_PERIOD + +def load_config_prefix(config): + return MmuESpooler(config) + + + +###################################################################### +# G-Code request queuing helper +# This is included to allow Kalico to work since it has not yet picked +# up this klipper functionality 4/18/25 +# Copyright (C) 2017-2024 Kevin O'Connor +###################################################################### + +PIN_MIN_TIME = 0.100 + +# Helper code to queue g-code requests +class GCodeRequestQueue: + def __init__(self, config, mcu, callback): + self.printer = printer = config.get_printer() + self.mcu = mcu + self.callback = callback + self.rqueue = [] + self.next_min_flush_time = 0. + self.toolhead = None + mcu.register_flush_callback(self._flush_notification) + printer.register_event_handler("klippy:connect", self._handle_connect) + def _handle_connect(self): + self.toolhead = self.printer.lookup_object('toolhead') + def _flush_notification(self, print_time, clock): + rqueue = self.rqueue + while rqueue: + next_time = max(rqueue[0][0], self.next_min_flush_time) + if next_time > print_time: + return + # Skip requests that have been overridden with a following request + pos = 0 + while pos + 1 < len(rqueue) and rqueue[pos + 1][0] <= next_time: + pos += 1 + req_pt, req_val = rqueue[pos] + # Invoke callback for the request + min_wait = 0. + ret = self.callback(next_time, req_val) + if ret is not None: + # Handle special cases + action, min_wait = ret + if action == "discard": + del rqueue[:pos+1] + continue + if action == "delay": + pos -= 1 + del rqueue[:pos+1] + self.next_min_flush_time = next_time + max(min_wait, PIN_MIN_TIME) + # Ensure following queue items are flushed + self.toolhead.note_mcu_movequeue_activity(self.next_min_flush_time) + def _queue_request(self, print_time, value): + self.rqueue.append((print_time, value)) + self.toolhead.note_mcu_movequeue_activity(print_time) + def queue_gcode_request(self, value): + self.toolhead.register_lookahead_callback( + (lambda pt: self._queue_request(pt, value))) + def send_async_request(self, value, print_time=None): + if print_time is None: + systime = self.printer.get_reactor().monotonic() + print_time = self.mcu.estimated_print_time(systime + PIN_MIN_TIME) + while 1: + next_time = max(print_time, self.next_min_flush_time) + # Invoke callback for the request + action, min_wait = "normal", 0. + ret = self.callback(next_time, value) + if ret is not None: + # Handle special cases + action, min_wait = ret + if action == "discard": + break + self.next_min_flush_time = next_time + max(min_wait, PIN_MIN_TIME) + if action != "delay": + break diff --git a/extras/mmu_led_effect.py b/extras/mmu_led_effect.py new file mode 100644 index 000000000000..8f160115ac2d --- /dev/null +++ b/extras/mmu_led_effect.py @@ -0,0 +1,1722 @@ +# Happy Hare MMU Software +# Wrapper around led_effect klipper module (included) to replicate any effect on entire strip +# as well as on each individual LED for per-gate effects. This relies on the [mmu_leds] section +# for each mmu_unit +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging + +# Klipper imports +from .mmu_leds import MmuLeds + +# [mmu_led_effect] is a simple wrapper that makes it easy to define led animations +# +# E.g. If you have setup the following config in mmu_hardware.cfg for 4-gate MMU +# [mmu_leds unit0] +# exit_leds: neopixel:mmu_leds (1-4) +# status_leds: neopixel:mmu_leds (5) +# +# E.g. You define "my_flash" like this: +# [mmu_led_effect my_flash] +# +# This will create effects on each of these segments elements without laborous +# error prone repetition: +# "unit0_mmu_flash_exit" on 'exit' portion of the strip (leds 1,2,3,4) +# "unit0_mmu_flash_status" on the status LED (led 5) +# "unit0_mmu_flash_exit_1" for gate 0 (led 1) +# "unit0_mmu_flash_exit_2" for gate 1 (led 2) +# "unit0_mmu_flash_exit_3" for gate 2 (led 3) +# "unit0_mmu_flash_exit_4" for gate 3 (led 4) +# +# Created effects can be restricted with by specifing 'define_on' and a let of segment +# names or 'gates' to indicate creation on exit/entry gates. If ommitted all possible +# effects will be created +# +# Then you can set effects with commands like: +# _MMU_SET_LED_EFFECT EFFECT=my_flash_exit # apply effect to all exit leds +# _MMU_SET_LED_EFFECT EFFECT=my_flash_exit_2 # apply effect entry led for gate #1 +# +# or set simple RBGW color with commands like: +# SET_LED LED=mmu_exit_led INDEX=2 RED=50 GREEN=50 BLUE=50 WHITE=0 TRANSMIT=1 +# +# Note that gates start at 0, but led indices and effect naming starts from 1, +# so remember: led_index = gate + 1 +# +class MmuLedEffect: + + def __init__(self, config): + self.printer = config.get_printer() + + mmu_machine = self.printer.lookup_object('mmu_machine', None) + if mmu_machine is None: + raise config.error("[mmu_led_effect] requires [mmu_machine] to be loaded first") + + define_on_str = config.get('define_on', "").strip() + _ = config.get('layers') + + for unit_index, mmu_unit in enumerate(mmu_machine.units): + mmu_leds = self.printer.lookup_object('mmu_leds %s' % mmu_unit.name, None) + if mmu_leds: + frame_rate = mmu_leds.frame_rate + define_on = [segment.strip() for segment in define_on_str.split(',') if segment.strip()] + if define_on and not all(e in MmuLeds.SEGMENTS + ['gates'] for e in define_on): + raise config.error("Unknown LED segment name specified in '%s'" % define_on_str) + config.fileconfig.set(config.get_name(), 'frame_rate', config.get('frame_rate', frame_rate)) + led_effect_section = config.get_name().split()[1] + + # This condition makes it a no-op if [mmu_leds] is not present or led_effects not installed + for segment in MmuLeds.SEGMENTS: + led_segment_name = "unit%d_mmu_%s_leds" % (unit_index, segment) + led_chain = self.printer.lookup_object(led_segment_name) + num_leds = led_chain.led_helper.led_count + leds_per_gate = num_leds // mmu_unit.num_gates + + if num_leds > 0: + # Full segment effects + if not define_on or segment in define_on: + section_to = "_led_effect unit%d_%s_%s" % (unit_index, led_effect_section, segment) + self._add_led_effect(config, section_to, led_segment_name) + + # Per gate + if segment in MmuLeds.PER_GATE_SEGMENTS and (not define_on or 'gates' in define_on): + for idx in range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates): + led0 = (idx - mmu_unit.first_gate) * leds_per_gate + 1 + led_spec = str(led0) + if leds_per_gate > 1: + led_spec = "%d-%d" % (led0, led0 + leds_per_gate - 1) + section_to = "_led_effect %s_%s_%d" % (led_effect_section, segment, idx) + self._add_led_effect(config, section_to, "%s (%s)" % (led_segment_name, led_spec)) + + def _add_led_effect(self, config, section_to, leds): + config.fileconfig.add_section(section_to) + config.fileconfig.set(section_to, 'leds', leds) + items = config.fileconfig.items(config.get_name()) + for item in (i for i in items if i[0] != 'define_on'): + config.fileconfig.set(section_to, item[0], item[1]) + + c = config.getsection(section_to) + led_effect = _ledEffect(c) + logging.info("MMU: Created: %s on %s" % (c.get_name(), leds)) + self.printer.add_object(c.get_name(), led_effect) # Register _led_effect to stop it trying to be loaded by klipper + +def load_config_prefix(config): + return MmuLedEffect(config) + + + + + + +# -------------------------------------------------------------------------------------- +# +# This is and embedded version (v0.0.18-0-g24d26c72) of the excellent led_effects +# It is included for ease of Happy Hare setup and to avoid dependencies that were +# tripping up users. It doesn't not prelude the adding of the original led-effects +# as well. +# +# Changes: +# - Command names have been changed to hide from original (added '_MMU_' prefix) +# - 'ledEffect' -> '_ledEffect' to avoid name collision with real module +# - def load_config_prefix(config) removed +# - self.handler loads object 'mmu_led_effect' +# +# -------------------------------------------------------------------------------------- + +# Support for addressable LED visual effects +# using neopixel and dotstar LEDs +# +# Copyright (C) 2020 Paul McGowan +# co-authored by Julian Schill +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +from math import cos, exp, pi +from random import randint + +ANALOG_SAMPLE_TIME = 0.001 +ANALOG_SAMPLE_COUNT = 5 +ANALOG_REPORT_TIME = 0.05 + +COLORS = 4 + +###################################################################### +# Custom color value list, returns lists of [r, g ,b] values +# from a one dimensional list +###################################################################### + +class colorArray(list): + def __init__(self, num_colors, kwargs): + self.n=num_colors + super(colorArray,self).__init__(kwargs) + + def __getitem__(self, a): + if isinstance(a, int): + return super(colorArray, self).__getitem__( + slice(a*self.n, a*self.n+self.n)) + if isinstance(a, slice): + start = a.start*self.n if a.start != None else None + stop = a.stop*self.n if a.stop != None else None + return colorArray(self.n, + super(colorArray, self).__getitem__( + slice(start, stop, a.step))) + def __getslice__(self, a, b): + return self.__getitem__(slice(a,b)) + def __setitem__(self, a, v): + if isinstance(a, int): + for i in range(self.n): + super(colorArray, self).__setitem__(a*self.n + i, v[i]) + def __len__(self): + return super(colorArray, self).__len__() // self.n + def reverse(self): + self.__init__(self.n, [c for cl in range(len(self)-1,-1, -1) + for c in self[cl]]) + def shift(self, shift=1, direction=True): + if direction: + shift *= -1 + self.__init__(self.n, self[shift:] + self[:shift]) + def padLeft(self, v, a): + self.__init__(self.n, v * a + self) + def padRight(self, v, a): + self += v * a + +###################################################################### +# LED Effect handler +###################################################################### + +class ledFrameHandler: + def __init__(self, config): + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') + self.printer.load_object(config, "display_status") + self.heaters = {} + self.printProgress = 0 + self.effects = [] + self.stepperPositions = [0.0,0.0,0.0] + self.stepperTimer = None + self.heaterCurrent = {} + self.heaterTarget = {} + self.heaterLast = {} + self.heaterTimer = None + self.homing = {} + self.homing_start_flag = {} + self.homing_end_flag = {} + self.printer.register_event_handler('klippy:ready', self._handle_ready) + self.printer.register_event_handler("homing:homing_move_begin", + self._handle_homing_move_begin) + self.printer.register_event_handler("homing:homing_move_end", + self._handle_homing_move_end) + self.ledChains=[] + self.gcode.register_command('_MMU_STOP_LED_EFFECTS', + self.cmd_STOP_LED_EFFECTS, + desc=self.cmd_STOP_LED_EFFECTS_help) + self.shutdown = False + + cmd_STOP_LED_EFFECTS_help = 'Stops all led_effects' + + def _transmit_chain(self, chain): + + # Force update (dotstar workaround) + if hasattr(chain, "prev_data"): + chain.prev_data = None + + helper = getattr(chain, 'led_helper', None) + if helper is None: + raise RuntimeError("Klipper version not compatible: chain has no 'led_helper'.") + + # Request a transmit + helper.need_transmit = True + + if hasattr(helper, '_check_transmit'): + helper._check_transmit() + elif hasattr(helper, 'check_transmit'): + # Older Klipper / Kalico API + helper.check_transmit(None) + else: + raise RuntimeError("Klipper version not compatible: led_helper missing '_check_transmit' and 'check_transmit'.") + + + def _handle_ready(self): + self.shutdown = False + self.reactor = self.printer.get_reactor() + self.printer.register_event_handler('klippy:shutdown', + self._handle_shutdown) + self.printProgress = 0 + self.displayStatus = self.printer.lookup_object('display_status') + self.progressTimer = self.reactor.register_timer(self._pollProgress, + self.reactor.NOW) + self.frameTimer = self.reactor.register_timer(self._getFrames, + self.reactor.NOW) + + def _handle_shutdown(self): + self.shutdown = True + for effect in self.effects: + if not effect.runOnShutown: + for chain in self.ledChains: + chain.led_helper.set_color(None, (0.0, 0.0, 0.0, 0.0)) + self._transmit_chain(chain) + + pass + + def _handle_homing_move_begin(self, hmove): + endstops_being_homed = [name for es,name in hmove.endstops] + + for endstop in endstops_being_homed: + if endstop in self.homing_start_flag: + self.homing_start_flag[endstop] += 1 + else: + self.homing_start_flag[endstop] = 0 + + self.homing[endstop]=True + + def _handle_homing_move_end(self, hmove): + endstops_being_homed = [name for es,name in hmove.endstops] + + for endstop in endstops_being_homed: + if endstop in self.homing_end_flag: + self.homing_end_flag[endstop] += 1 + else: + self.homing_end_flag[endstop] = 0 + self.homing[endstop]=False + + def addEffect(self, effect): + + if effect.heater: + effect.heater=effect.heater.strip('\"\'') + if effect.heater.startswith("temperature_fan ") or effect.heater.startswith("temperature_sensor "): + self.heaters[effect.heater] = self.printer.lookup_object(effect.heater) + else: + pheater = self.printer.lookup_object('heaters') + + heater = pheater.lookup_heater(effect.heater) + if heater is None: + raise self.printer.config_error( + "LED Effect '%s': unknown heater '%s'." + % (effect.name, effect.heater,)) + self.heaters[effect.heater] = heater + self.heaterLast[effect.heater] = 100 + self.heaterCurrent[effect.heater] = 0 + self.heaterTarget[effect.heater] = 0 + + if not self.heaterTimer: + self.heaterTimer = self.reactor.register_timer(self._pollHeater, + self.reactor.NOW) + + if effect.stepper: + self.toolhead = self.printer.lookup_object('toolhead') + self.kin = self.toolhead.get_kinematics() + + if not self.stepperTimer: + self.stepperTimer = self.reactor.register_timer( + self._pollStepper, + self.reactor.NOW) + + if effect in self.effects: + self.effects.remove(effect) + + self.effects.append(effect) + + def _pollHeater(self, eventtime): + for heater in self.heaters.keys(): + current, target = self.heaters[heater].get_temp(eventtime) + self.heaterCurrent[heater] = current + self.heaterTarget[heater] = target + if target > 0: + self.heaterLast[heater] = target + return eventtime + 0.3 #sensors get updated every 300ms + + def _pollStepper(self, eventtime): + + kin_spos = {s.get_name(): s.get_commanded_position() + for s in self.kin.get_steppers()} + + pos = self.kin.calc_position(kin_spos) + + for i in range(3): + if pos[i] >= self.kin.axes_min[i] and pos[i] <= self.kin.axes_max[i]: + self.stepperPositions[i] = int( + ((pos[i] - self.kin.axes_min[i]) / \ + (self.kin.axes_max[i] - self.kin.axes_min[i]) + * 100)- 1) + return eventtime + 0.5 + + def _pollProgress(self, eventtime): + status = self.displayStatus.get_status(eventtime) + p = status.get('progress') + if p is not None: + self.printProgress = int(p * 100) + return eventtime + 1 + + def _getColorData(self, colors, fade): + clamp = (lambda x : 0.0 if x < 0.0 else 1.0 if x > 1.0 else x) + colors = [x*clamp(fade) for x in colors] + colors=colors + [0.0] * (4 - len(colors)) + colors=colors[:4] + colors = [clamp(x) for x in colors] + return tuple(colors) + + def _getFrames(self, eventtime): + chainsToUpdate = set() + + frames = [(effect, effect.getFrame(eventtime)) for effect in self.effects] + + #first set all LEDs to 0, that should be updated + for effect, (frame, update) in frames: + if update: + for i in range(effect.ledCount): + chain,index=effect.leds[i] + chain.led_helper.led_state[index] = (0.0, 0.0, 0.0, 0.0) + chainsToUpdate.add(chain) + + #then sum up all effects for that LEDs + for effect, (frame, update) in frames: + if update: + for i in range(effect.ledCount): + chain,index=effect.leds[i] + + current_state=list(chain.led_helper.led_state[index]) + effect_state=self._getColorData(frame[i*COLORS:i*COLORS+COLORS], + effect.fadeValue) + + next_state=[min(1.0,a+b) for a,b in \ + zip(current_state, effect_state)] + + chain.led_helper.led_state[index] = tuple(next_state) + chainsToUpdate.add(chain) + + for chain in chainsToUpdate: + if not self.shutdown: + self._transmit_chain(chain) + if self.effects: + next_eventtime=min(self.effects, key=lambda x: x.nextEventTime)\ + .nextEventTime + else: + next_eventtime = eventtime + # run at least with 10Hz + next_eventtime=min(next_eventtime, eventtime + 0.1) + return next_eventtime + + def parse_chain(self, chain): + chain = chain.strip() + leds=[] + parms = [parameter.strip() for parameter in chain.split() + if parameter.strip()] + if parms: + chainName=parms[0].replace(':',' ') + ledIndices = ''.join(parms[1:]).strip('()').split(',') + for led in ledIndices: + if led: + if '-' in led: + start, stop = map(int,led.split('-')) + if stop == start: + ledList = [start-1] + elif stop > start: + ledList = list(range(start-1, stop)) + else: + ledList = list(reversed(range(stop-1, start))) + for i in ledList: + leds.append(int(i)) + else: + for i in led.split(','): + leds.append(int(i)-1) + + return chainName, leds + else: + return None, None + + def cmd_STOP_LED_EFFECTS(self, gcmd): + ledParam = gcmd.get('LEDS', "") + stopAll = (ledParam == "") + + for effect in self.effects: + stopEffect = stopAll + if not stopAll: + try: + chainName, ledIndices = self.parse_chain(ledParam) + chain = self.printer.lookup_object(chainName) + except Exception as e: + raise gcmd.error("Unknown LED '%s'" % (ledParam,)) + + if ledIndices == [] and chain in effect.ledChains: + stopEffect = True + else: + for index in ledIndices: + if (chain,index) in effect.leds: + stopEffect=True + + if stopEffect: + if effect.enabled: + effect.set_fade_time(gcmd.get_float('FADETIME', 0.0)) + effect.set_enabled(False) + +def load_config(config): + return ledFrameHandler(config) + +###################################################################### +# LED Effect +###################################################################### + +class _ledEffect: + def __init__(self, config): + self.config = config + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') + self.gcode_macro = self.printer.load_object(config, 'gcode_macro') + self.handler = self.printer.load_object(config, 'mmu_led_effect') + self.frameRate = 1.0 / config.getfloat('frame_rate', + default=24, minval=1, maxval=60) + self.enabled = False + self.iteration = 0 + self.layers = [] + self.analogValue = 0 + self.button_state = 0 + self.fadeValue = 0.0 + self.fadeTime = 0.0 + self.fadeEndTime = 0 + + #Basic functions for layering colors. t=top and b=bottom color + self.blendingModes = { + 'top' : (lambda t, b: t ), + 'bottom' : (lambda t, b: b ), + 'add' : (lambda t, b: t + b ), + 'subtract' : (lambda t, b: (b - t) * (b - t > 0)), + 'subtract_b': (lambda t, b: (t - b) * (t - b > 0)), + 'difference': (lambda t, b: (t - b) * (t > b) + (b - t) * (t <= b)), + 'average' : (lambda t, b: 0.5 * (t + b)), + 'multiply' : (lambda t, b: t * b), + 'divide' : (lambda t, b: t / b if b > 0 else 0 ), + 'divide_inv': (lambda t, b: b / t if t > 0 else 0 ), + 'screen' : (lambda t, b: 1.0 - (1.0-t)*(1.0-b) ), + 'lighten' : (lambda t, b: t * (t > b) + b * (t <= b)), + 'darken' : (lambda t, b: t * (t < b) + b * (t >= b)), + 'overlay' : (lambda t, b: \ + 2.0 * t * b if t > 0.5 else \ + 1.0 - (2.0 * (1.0-t) * (1.0-b))) + } + + self.name = config.get_name().split()[1] + + self.autoStart = config.getboolean('autostart', False) + self.runOnShutown = config.getboolean('run_on_error', False) + self.heater = config.get('heater', None) + self.analogPin = config.get('analog_pin', None) + self.buttonPins = config.getlist('button_pins', None) + self.stepper = config.get('stepper', None) + self.recalculate = config.get('recalculate', False) + self.endstops = [x.strip() for x in config.get('endstops','').split(',')] + self.layerTempl = self.gcode_macro.load_template(config, 'layers') + self.configLayers = [] + self.configLeds = config.get('leds') + + self.nextEventTime = 0 + self.printer.register_event_handler('klippy:ready', self._handle_ready) + self.gcode.register_mux_command('_MMU_SET_LED_EFFECT', 'EFFECT', self.name, + self.cmd_SET_LED_EFFECT, + desc=self.cmd_SET_LED_EFFECT_help) + + if self.analogPin: + ppins = self.printer.lookup_object('pins') + self.mcu_adc = ppins.setup_pin('adc', self.analogPin) + if hasattr(self.mcu_adc, 'setup_adc_sample'): + try: + # New klipper (>= v0.13.0-557) + self.mcu_adc.setup_adc_sample(ANALOG_REPORT_TIME, ANALOG_SAMPLE_TIME, ANALOG_SAMPLE_COUNT) + self.mcu_adc.setup_adc_callback(self.adcCallback) + except TypeError: + # A few versions of klipper had these signatures + self.mcu_adc.setup_adc_sample(ANALOG_SAMPLE_TIME, ANALOG_SAMPLE_COUNT) + self.mcu_adc.setup_adc_callback(ANALOG_REPORT_TIME, self.adcCallback) + elif hasattr(self.mcu_adc, 'setup_minmax'): + # Kalico and older klipper + self.mcu_adc.setup_minmax(ANALOG_SAMPLE_TIME, ANALOG_SAMPLE_COUNT) + self.mcu_adc.setup_adc_callback(ANALOG_REPORT_TIME, self.adcCallback) + else: + raise RuntimeError( + "Klipper version not compatible: mcu_adc missing 'setup_adc_sample' and 'setup_minmax'.") + + query_adc = self.printer.load_object(self.config, 'query_adc') + query_adc.register_adc(self.name, self.mcu_adc) + + if self.buttonPins: + buttons = self.printer.load_object(config, "buttons") + buttons.register_buttons(self.buttonPins, self.button_callback) + + cmd_SET_LED_EFFECT_help = 'Starts or Stops the specified led_effect' + + def _handle_ready(self): + self.configChains = self.configLeds.split('\n') + self.ledChains = [] + self.leds = [] + self.enabled = self.autoStart + if not self.enabled: + self.nextEventTime = self.handler.reactor.NEVER + self.printer.register_event_handler('klippy:shutdown', + self._handle_shutdown) + #map each LED from the chains to the "pixels" in the effect frame + for chain in self.configChains: + chainName, ledIndices = self.handler.parse_chain(chain) + if chainName is not None: + ledChain = self.printer.lookup_object(chainName) + + #Add each discrete chain to the collection + if ledChain not in self.ledChains: + self.ledChains.append(ledChain) + + if ledIndices == [] : + for i in range(ledChain.led_helper.led_count): + self.leds.append((ledChain, int(i))) + else: + for led in ledIndices: + if led > ledChain.led_helper.led_count: + raise self.printer.config_error( + "LED effect '%s': index out of range for chain '%s' with %d LEDs." + % (self.name, chainName, ledChain.led_helper.led_count)) + self.leds.append((ledChain, led)) + + self.ledCount = len(self.leds) + self.frame = [0.0] * COLORS * self.ledCount + + #enumerate all effects from the subclasses of _layerBase... + self.availableLayers = {str(c).rpartition('.layer')[2]\ + .replace("'>", "")\ + .lower() : c + for c in self._layerBase.__subclasses__() + if str(c).startswith(" COLORS: + raise Exception( + "LED effect '%s': Color %s has too many elements." % (self.name, str(i),)) + palette=[pad(c) for c in palette] # pad to COLORS colors + palette=[k for c in palette for k in c] # flatten list + except Exception as e: + raise self.printer.config_error( + "LED effect '%s': Error parsing palette in '%s' for layer \"%s\": %s"\ + % (self.name, self.config.get_name(), parms[0], e,)) + self.layers.insert(0, layer(handler = self, + frameHandler = self.handler, + effectRate = float(parms[1]), + effectCutoff = float(parms[2]), + paletteColors = palette, + frameRate = self.frameRate, + ledCount = len(self.leds), + blendingMode = parms[3])) + + self.handler.addEffect(self) + + def getFrame(self, eventtime): + if not self.enabled and self.fadeValue <= 0.0: + if self.nextEventTime < self.handler.reactor.NEVER: + # Effect has just been disabled. Set colors to 0 and update once. + self.nextEventTime = self.handler.reactor.NEVER + self.frame = [0.0] * COLORS * self.ledCount + update = True + else: + update = False + else: + update = True + if eventtime >= self.nextEventTime: + self.nextEventTime = eventtime + self.frameRate + + self.frame = [0.0] * COLORS * self.ledCount + for layer in self.layers: + layerFrame = layer.nextFrame(eventtime) + + if layerFrame: + blend = self.blendingModes[layer.blendingMode] + self.frame = [blend(t, b) for t, b in zip(layerFrame, self.frame)] + + if (self.fadeEndTime > eventtime) and (self.fadeTime > 0.0): + remainingFade = ((self.fadeEndTime - eventtime) / self.fadeTime) + else: + remainingFade = 0.0 + + self.fadeValue = 1.0-remainingFade if self.enabled else remainingFade + + return self.frame, update + + def set_enabled(self, state): + if self.enabled != state: + self.enabled = state + self.nextEventTime = self.handler.reactor.NOW + self.handler._getFrames(self.handler.reactor.NOW) + + def reset_frame(self): + for layer in self.layers: + layer.frameNumber = 0 + + def set_fade_time(self, fadetime): + self.fadeTime = fadetime + self.fadeEndTime = self.handler.reactor.monotonic() + fadetime + if self.fadeTime == 0.0: + self.fadeValue = 0.0 + + def cmd_SET_LED_EFFECT(self, gcmd): + parmFadeTime = gcmd.get_float('FADETIME', 0.0) + + if gcmd.get_int('STOP', 0) >= 1: + if self.enabled: + self.set_fade_time(parmFadeTime) + self.set_enabled(False) + else: + if self.recalculate: + kwargs = self.layerTempl.create_template_context() + kwargs['params'] = gcmd.get_command_parameters() + kwargs['rawparams'] = gcmd.get_raw_command_parameters() + self._generateLayers(kwargs) + if gcmd.get_int('REPLACE',0) >= 1: + for led in self.leds: + for effect in self.handler.effects: + if effect is not self and led in effect.leds: + if effect.enabled: + effect.set_fade_time(parmFadeTime) + effect.set_enabled(False) + + if not self.enabled: + self.set_fade_time(parmFadeTime) + if gcmd.get_int('RESTART', 0) >= 1: + self.reset_frame() + self.set_enabled(True) + + def get_status(self, eventtime): + return {'enabled':self.enabled} + + def _handle_shutdown(self): + self.set_enabled(self.runOnShutown) + + def adcCallback(self, *args): + # Old klipper: _adc_callback(read_time, read_value) + # New klipper: _adc_callback(samples) where samples is a list of (read_time, read_value) + if len(args) == 1: + samples = args[0] + read_time, read_value = samples[-1] + elif len(args) == 2: + read_time, read_value = args + else: + raise TypeError("_adc_callback expected (read_time, read_value) or (samples), got %d args" % len(args)) + + self.analogValue = int(read_value * 1000.0) / 10.0 + + def button_callback(self, eventtime, state): + self.button_state = state + + ###################################################################### + # LED Effect layers + ###################################################################### + + # super class for effect animations. new animations should + # inherit this and return 1 frame of [r, g, b] * + # per call of nextFrame() + class _layerBase(object): + def __init__(self, **kwargs): + self.handler = kwargs['handler'] + self.frameHandler = kwargs['frameHandler'] + self.ledCount = kwargs['ledCount'] + self.paletteColors = colorArray(COLORS, kwargs['paletteColors']) + self.effectRate = kwargs['effectRate'] + self.effectCutoff = kwargs['effectCutoff'] + self.frameRate = kwargs['frameRate'] + self.blendingMode = kwargs['blendingMode'] + self.frameNumber = 0 + self.thisFrame = [] + self.frameCount = 1 + self.lastAnalog = 0 + + def nextFrame(self, eventtime): + if not self.frameCount: + return [0] * COLORS * self.ledCount + self.frameNumber += 1 + self.frameNumber = self.frameNumber * \ + ( self.frameNumber < self.frameCount ) + self.lastFrameTime = eventtime + + return self.thisFrame[self.frameNumber] + + def _decayTable(self, factor=1, rate=1): + + frame = [] + + p = (1.0 / self.frameRate) + r = (p/15.0)*factor + + for s in range(0, int((rate<1)+rate)): + frame.append(1.0) + for x in range(2, int(p / rate)): + b = exp(1)**-(x/r) + if b>.004: + frame.append(b) + return frame + + def _gradient(self, palette, steps, reverse=False, toFirst=False): + palette = colorArray(COLORS, palette[:]) + if reverse: palette.reverse() + + if len(palette) == 1: + return colorArray(COLORS, palette * steps) + + if toFirst: + palette += palette[0] + + paletteIntervals = len(palette)-1 + stepIntervals = steps if toFirst else steps-1 + if stepIntervals != 0: + intervals_per_step = float(paletteIntervals) / stepIntervals + else: + intervals_per_step = 0 + + gradient=palette[0] + + for i in range(1,steps): + j = intervals_per_step * i + k = int(j) + r = j-k + k = min(k, len(palette)-1) + + if ( (k+1) >= len(palette) ) | (r == 0.0) : + z = palette[k] + else: + z = [((1-r)*palette[k][m] + r*palette[k+1][m]) for m in range(COLORS)] + gradient += z + return gradient + + #Individual effects inherit from the LED Effect Base class + #each effect must support the nextFrame() method either by + #using the method from the base class or overriding it. + + #Solid color + class layerStatic(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerStatic, self).__init__(**kwargs) + + self.paletteColors = colorArray(COLORS, self.paletteColors) + + gradientLength = int(self.ledCount) + gradient = colorArray(COLORS, self._gradient(self.paletteColors, + gradientLength)) + + self.thisFrame.append(gradient[0:self.ledCount]) + self.frameCount = len(self.thisFrame) + + #Slow pulsing of color + class layerBreathing(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerBreathing, self).__init__(**kwargs) + + brightness = [] + + p = (1 / self.frameRate) * (self.effectRate * 0.5) + o = int(p) + f = 2 * pi + + for x in range(0, int(p)): + if x < p: + v = (exp(-cos((f / p) * (x+o)))-0.367879) / 2.35040238 + else: + v = 0 + + #clamp values + if v > 1.0: + v = 1.0 + elif v < 0.0: + v = 0.0 + + brightness.append(v) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + + for b in brightness: + self.thisFrame += [[b * i for i in color] * self.ledCount] + + self.frameCount = len(self.thisFrame) + class layerLinearFade(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerLinearFade, self).__init__(**kwargs) + + gradientLength = int(self.effectRate / self.frameRate) + if gradientLength == 0: gradientLength = 1 + + gradient = colorArray(COLORS, self._gradient(self.paletteColors, + gradientLength, toFirst=True)) + + for i in range(gradientLength): + self.thisFrame.append(gradient[i]*self.ledCount) + + self.frameCount = len(self.thisFrame) + + #Turns the entire strip on and off + class layerBlink(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerBlink, self).__init__(**kwargs) + + dutyCycle= max(0,min(1.0, self.effectCutoff)) + frameCountOn = int(( 1.0 / self.frameRate ) * self.effectRate\ + * dutyCycle) + frameCountOff = int(( 1.0 / self.frameRate ) * self.effectRate\ + * (1-dutyCycle)) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame += [color * self.ledCount] * frameCountOn + self.thisFrame += [[0]*COLORS * self.ledCount] * frameCountOff + + self.frameCount = len(self.thisFrame) + + #Random flashes with decay + class layerTwinkle(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerTwinkle, self).__init__(**kwargs) + + self.thisFrame = colorArray(COLORS, ([0.0]*COLORS) * self.ledCount) + self.lastBrightness = [-1] * self.ledCount + self.decayTable = self._decayTable(factor=1 / self.effectCutoff) + self.decayLen = len(self.decayTable) + self.colorCount = len(self.paletteColors) - 1 + + def nextFrame(self, eventtime): + + for i in range(0, self.ledCount): + + r = randint(0, self.colorCount) + color = self.paletteColors[r] + + if randint(0, 255) > 254 - self.effectRate: + self.lastBrightness[i] = 0 + self.thisFrame[i] = color + + if self.lastBrightness[i] != -1: + if self.lastBrightness[i] == self.decayLen: + self.lastBrightness[i] = -1 + self.thisFrame[i] = ([0.0]*COLORS) + else: + x = self.lastBrightness[i] + self.lastBrightness[i] += 1 + self.thisFrame[i] = [self.decayTable[x] * l + for l in self.thisFrame[i]] + + return self.thisFrame + + #Blinking with decay + class layerStrobe(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerStrobe, self).__init__(**kwargs) + + frameRate = int(1.0 / self.frameRate) + if self.effectRate==0: + frameCount = 1 + else: + frameCount = max(1,int(frameRate / self.effectRate)) + if self.effectCutoff==0: self.effectCutoff=0.001 + decayTable = self._decayTable(factor=1 / self.effectCutoff, + rate=1) + if len(decayTable) > frameCount: + decayTable = decayTable[:frameCount] + else: + decayTable += [0.0] * (frameCount - len(decayTable)) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + + for b in decayTable: + self.thisFrame += [[b * i for i in color] * self.ledCount] + + self.frameCount = len(self.thisFrame) + + #Lights move sequentially with decay + class layerComet(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerComet, self).__init__(**kwargs) + if self.effectRate > 0: + self.direction = True + else: + self.direction = False + self.effectRate *= -1 + + if self.effectCutoff <= 0: self.effectCutoff = .1 + + decayTable = self._decayTable(factor=len(self.paletteColors) * \ + self.effectCutoff, rate=1) + + gradient = self.paletteColors[0] + \ + self._gradient(self.paletteColors[1:], len(decayTable)+1) + + decayTable = [c for b in zip(decayTable, decayTable, decayTable, decayTable) \ + for c in b] + + comet = colorArray(COLORS, [a * b for a, b in zip(gradient,decayTable)]) + + comet.padRight([0.0]*COLORS, self.ledCount - len(comet)) + + if self.direction: comet.reverse() + else: comet.shift(self.ledCount - len(comet)) + + if self.effectRate == 0: + self.thisFrame.append(comet[0:self.ledCount]) + else: + for i in range(len(comet)): + comet.shift(int(self.effectRate+(self.effectRate < 1)), + self.direction) + self.thisFrame.append(comet[:self.ledCount]) + + for x in range(int((1/self.effectRate)-(self.effectRate <= 1))): + self.thisFrame.append(comet[:self.ledCount]) + + self.frameCount = len(self.thisFrame) + + #Lights move sequentially with decay + class layerChase(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerChase, self).__init__(**kwargs) + + if self.effectRate > 0: + self.direction = True + else: + self.direction = False + self.effectRate *= -1 + + if len(self.paletteColors) == 1: + self.paletteColors += colorArray(COLORS,COLORS*[0]) + + decayTable = self._decayTable(factor=len(self.paletteColors) * \ + self.effectCutoff, rate=1) + + gradient = self.paletteColors[0] + \ + self._gradient(self.paletteColors[1:], len(decayTable)+1) + + decayTable = [c for b in zip(decayTable, decayTable, decayTable, decayTable) \ + for c in b] + gradient = colorArray(COLORS, [a * b + for a, b in zip(gradient,decayTable)]) + + k=int(self.ledCount/len(gradient))+1 + chase = colorArray(COLORS,k*gradient) + + if self.direction: chase.reverse() + if self.effectRate == 0: + self.thisFrame.append(chase[0:self.ledCount]) + else: + for _ in range(len(chase)): + chase.shift(int(self.effectRate+(self.effectRate < 1)), + self.direction) + self.thisFrame.append(chase[0:self.ledCount]) + + for _ in range(int((1/self.effectRate)-(self.effectRate <= 1))): + self.thisFrame.append(chase[0:self.ledCount]) + + self.frameCount = len(self.thisFrame) + + #Cylon, single LED bounces from start to end of strip + class layerCylon(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerCylon, self).__init__(**kwargs) + + self.paletteColors = colorArray(COLORS, self.paletteColors) + + if self.effectRate <= 0: + raise self.handler.printer.config_error( + "LED Effect '%s': effect rate for cylon must be > 0" % (self.handler.name,)) + + # How many frames per sweep animation. + frames = int(self.effectRate / self.frameRate) + + direction = True + + for _ in range(len(self.paletteColors) % 2 + 1): + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + + for frame in range(frames): + pct = frame / (frames - 1) + newFrame = [] + + p = int(round((self.ledCount - 2) * pct) if direction else 1 + round(((self.ledCount - 2) * (1 - pct)))) + + for i in range(self.ledCount): + newFrame += color if p == i else [0.0] * COLORS + + self.thisFrame.append(newFrame) + + direction = not direction + + self.frameCount = len(self.thisFrame) + + #Color gradient over all LEDs + class layerGradient(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerGradient, self).__init__(**kwargs) + + direction = -1 if self.effectRate < 0 else 1 + + if self.effectRate == 0: + gradientLength = self.ledCount + else: + gradientLength=abs(int(1/(self.effectRate * self.frameRate))) + gradient = colorArray(COLORS, self._gradient(self.paletteColors, + gradientLength, + toFirst=True)) + + for i in range(gradientLength if self.effectRate != 0 else 1): + frame = colorArray(COLORS, ([0.0]*COLORS) * self.ledCount) + for led in range(self.ledCount): + frame[led] = gradient[ int(i*direction + \ + self.effectCutoff * gradientLength * led \ + / self.ledCount ) % gradientLength] + self.thisFrame.append(frame) + + self.frameCount = len(self.thisFrame) + + class layerPattern(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerPattern, self).__init__(**kwargs) + + self.paletteColors = colorArray(COLORS, self.paletteColors) + frame = colorArray(COLORS, []) + + for i in range(int(self.ledCount/len(self.paletteColors))+1): + frame+=(self.paletteColors) + + if int(self.effectRate/self.frameRate) == 0: + self.thisFrame.append(frame) + else: + for _ in range(len(self.paletteColors) * (self.ledCount-1)): + for _ in range(int(self.effectRate/self.frameRate)): + self.thisFrame.append(colorArray(COLORS, frame)[:COLORS*self.ledCount]) + frame.shift(int(self.effectCutoff)) + + self.frameCount = len(self.thisFrame) + + #Responds to heater temperature + class layerHeater(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerHeater, self).__init__(**kwargs) + + if len(self.paletteColors) == 1: + self.paletteColors += self.paletteColors + + gradient = colorArray(COLORS, self._gradient(self.paletteColors[:-1], 200) + + self.paletteColors[-1:]) + + for i in range(len(gradient)): + self.thisFrame.append(gradient[i] * self.ledCount) + + self.frameCount = len(self.thisFrame) + + if self.handler.heater is None: + raise self.handler.printer.config_error( + "LED Effect '%s' has no heater defined." % (self.handler.name)) + + def nextFrame(self, eventtime): + heaterTarget = self.frameHandler.heaterTarget[self.handler.heater] + heaterCurrent = self.frameHandler.heaterCurrent[self.handler.heater] + heaterLast = self.frameHandler.heaterLast[self.handler.heater] + + if heaterTarget > 0.0 and heaterCurrent > 0.0: + if (heaterCurrent >= self.effectRate): + if (heaterCurrent <= heaterTarget-2): + s = int(((heaterCurrent - self.effectRate) / (heaterTarget - self.effectRate)) * 200) + s = min(len(self.thisFrame)-1,s) + return self.thisFrame[s] + elif self.effectCutoff > 0: + return None + else: + return self.thisFrame[-1] + else: + return None + + elif self.effectRate > 0 and heaterCurrent > 0.0: + if heaterCurrent >= self.effectRate and heaterLast > 0: + s = int(((heaterCurrent - self.effectRate) / heaterLast) * 200) + s = min(len(self.thisFrame)-1,s) + return self.thisFrame[s] + + return None + + #Responds to heater temperature + class layerTemperature(_layerBase): + def __init__(self, **kwargs): + + super(_ledEffect.layerTemperature, self).__init__(**kwargs) + if len(self.paletteColors) == 1: + self.paletteColors = colorArray(COLORS, ([0.0]*COLORS)) + self.paletteColors + gradient = colorArray(COLORS, self._gradient(self.paletteColors, 200)) + for i in range(len(gradient)): + self.thisFrame.append(gradient[i] * self.ledCount) + self.frameCount = len(self.thisFrame) + + if self.handler.heater is None: + raise self.handler.printer.config_error( + "LED Effect '%s' has no heater defined." % (self.handler.name)) + + def nextFrame(self, eventtime): + if self.effectCutoff == self.effectRate: + s = 200 if self.frameHandler.heaterCurrent[self.handler.heater] >= self.effectRate else 0 + else: + s = int(((self.frameHandler.heaterCurrent[self.handler.heater] - + self.effectRate) / + (self.effectCutoff - self.effectRate)) * 200) + + s = min(len(self.thisFrame)-1,s) + s = max(0,s) + return self.thisFrame[s] + class layerHeaterGauge(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerHeaterGauge, self).__init__(**kwargs) + + if self.effectRate < 0: + self.effectRate = self.ledCount + + if self.effectCutoff < 0: + self.effectCutoff = self.ledCount + + if self.effectRate == 0: + trailing = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + trailing = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectRate), True)) + trailing.padLeft([0.0]*COLORS, self.ledCount) + + if self.effectCutoff == 0: + leading = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + leading = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectCutoff), False)) + leading.padRight([0.0]*COLORS, self.ledCount) + + gradient = colorArray(COLORS, trailing + self.paletteColors[0] + leading) + gradient.shift(len(trailing), 0) + frames = [gradient[:self.ledCount]] + + for i in range(0, self.ledCount): + gradient.shift(1,1) + frames.append(gradient[:self.ledCount]) + + self.thisFrame.append(colorArray(COLORS, [0.0]*COLORS * self.ledCount)) + for i in range(1, 101): + x = int((i / 101.0) * self.ledCount) + self.thisFrame.append(frames[x]) + + self.frameCount = len(self.thisFrame) + + def nextFrame(self, eventtime): + heaterTarget = self.frameHandler.heaterTarget[self.handler.heater] + heaterCurrent = self.frameHandler.heaterCurrent[self.handler.heater] + heaterLast = self.frameHandler.heaterLast[self.handler.heater] + + if heaterTarget > 0.0: + p = int(heaterCurrent/heaterTarget * 100.0) + elif heaterLast > 0.0: + p = int(heaterCurrent/heaterLast * 100.0) + else: + p = 0 + + p = min(len(self.thisFrame)-1,p) + p = max(0,p) + + return self.thisFrame[p] + + class layerTemperatureGauge(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerTemperatureGauge, self).__init__(**kwargs) + + trailing = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.ledCount), True)) + trailing.padLeft([0.0]*COLORS, self.ledCount) + + leading = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + + gradient = colorArray(COLORS, trailing + self.paletteColors[0] + leading) + gradient.shift(len(trailing), 0) + frames = [gradient[:self.ledCount]] + + for i in range(0, self.ledCount): + gradient.shift(1,1) + frames.append(gradient[:self.ledCount]) + + self.thisFrame.append(colorArray(COLORS, [0.0]*COLORS * self.ledCount)) + self.steps = 255 + for i in range(1, self.steps + 1): + x = int((i / float(self.steps + 1)) * self.ledCount) + frames2=colorArray(COLORS,[]) + + for idx,led in enumerate(frames[x]): + + brightness = min(1.0,max(0.0,len(frames[x]) * (float(i) / float(self.steps + 1)) - int(idx/COLORS))) + + frames2.append(led*brightness) + + self.thisFrame.append(frames2) + + self.frameCount = len(self.thisFrame) + + def nextFrame(self, eventtime): + if self.effectCutoff == self.effectRate: + s = len(self.thisFrame) if self.frameHandler.heaterCurrent[self.handler.heater] >= self.effectRate else 0 + else: + s = int(((self.frameHandler.heaterCurrent[self.handler.heater] - + self.effectRate) / + (self.effectCutoff - self.effectRate)) * self.steps) + + s = min(len(self.thisFrame)-1,s) + s = max(0,s) + + + + return self.thisFrame[s] + + + #Responds to analog pin voltage + class layerAnalogPin(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerAnalogPin, self).__init__(**kwargs) + + if len(self.paletteColors) == 1: + self.paletteColors = [0.0]*COLORS + self.paletteColors + + gradient = colorArray(COLORS, self._gradient(self.paletteColors, 101)) + + for i in range(len(gradient)): + self.thisFrame.append(gradient[i] * self.ledCount) + + def nextFrame(self, eventtime): + v = int(self.handler.analogValue * self.effectRate) + + if v > 100: v = 100 + + if v > self.effectCutoff: + return self.thisFrame[v] + else: + return self.thisFrame[0] + + #Lights illuminate relative to stepper position + class layerStepper(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerStepper, self).__init__(**kwargs) + + if self.effectRate < 0: + self.effectRate = self.ledCount + + if self.effectCutoff < 0: + self.effectCutoff = self.ledCount + + if self.effectRate == 0: + trailing = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + trailing = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectRate), True)) + trailing.padLeft([0.0]*COLORS, self.ledCount) + + if self.effectCutoff == 0: + leading = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + leading = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectCutoff), False)) + leading.padRight([0.0]*COLORS, self.ledCount) + + gradient = colorArray(COLORS, trailing + self.paletteColors[0] + leading) + gradient.shift(len(trailing)-1, 0) + frames = [gradient[:self.ledCount]] + + for i in range(0, self.ledCount): + gradient.shift(1,1) + frames.append(gradient[:self.ledCount]) + + for i in range(101): + x = int((i / 101.0) * self.ledCount) + self.thisFrame.append(frames[x]) + + self.frameCount = len(self.thisFrame) + + def nextFrame(self, eventtime): + if self.handler.stepper == 'x': axis = 0 + elif self.handler.stepper == 'y': axis = 1 + else: axis = 2 + + p = self.frameHandler.stepperPositions[int(axis)] + + if p < 0 : p=0 + if p > 100 : p=100 + return self.thisFrame[int((p - 1) * (p > 0))] + + class layerStepperColor(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerStepperColor, self).__init__(**kwargs) + + if len(self.paletteColors) == 1: + self.paletteColors = [0.0]*COLORS + self.paletteColors + + gradient = colorArray(COLORS, self._gradient(self.paletteColors, 101)) + + for i in range(len(gradient)): + self.thisFrame.append(gradient[i] * self.ledCount) + + def nextFrame(self, eventtime): + if self.handler.stepper == 'x': axis = 0 + elif self.handler.stepper == 'y': axis = 1 + else: axis = 2 + + p = self.frameHandler.stepperPositions[int(axis)]*self.effectRate+self.effectCutoff + + if p < 0 : p=0 + if p > 100 : p=100 + + return self.thisFrame[int(p)] + + #Shameless port of Fire2012 by Mark Kriegsman + + #Shamelessly appropriated from the Arduino FastLED example files + #Fire2012.ino by Daniel Garcia + class layerFire(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerFire, self).__init__(**kwargs) + + self.heatMap = [0.0] * self.ledCount + self.gradient = colorArray(COLORS, self._gradient(self.paletteColors, + 102)) + self.frameLen = len(self.gradient) + self.heatLen = len(self.heatMap) + self.heatSource = int(self.ledCount / 10.0) + self.effectRate = int(self.effectRate) + + if self.heatSource < 1: + self.heatSource = 1 + + def nextFrame(self, eventtime): + frame = [] + + for h in range(self.heatLen): + c = randint(0,int(self.effectCutoff)) + self.heatMap[h] -= (self.heatMap[h] - c >= 0 ) * c + + for i in range(self.ledCount - 1, self.heatSource, -1): + d = (self.heatMap[i - 1] + + self.heatMap[i - 2] + + self.heatMap[i - 3] ) / 3 + + self.heatMap[i] = d * (d >= 0) + + if randint(0, 100) < self.effectRate: + h = randint(0, self.heatSource) + self.heatMap[h] += randint(90,100) + if self.heatMap[h] > 100: + self.heatMap[h] = 100 + + for h in self.heatMap: + frame += self.gradient[int(h)] + + return frame + + #Fire that responds relative to actual vs target temp + class layerHeaterFire(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerHeaterFire, self).__init__(**kwargs) + + self.heatMap = [0.0] * self.ledCount + self.gradient = colorArray(COLORS, self._gradient(self.paletteColors, + 102)) + self.frameLen = len(self.gradient) + self.heatLen = len(self.heatMap) + self.heatSource = int(self.ledCount / 10.0) + + if self.handler.heater is None: + raise self.handler.printer.config_error( + "LED Effect '%s' has no heater defined." % (self.handler.name)) + + if self.heatSource < 1: + self.heatSource = 1 + + def nextFrame(self, eventtime): + frame = [] + spark = 0 + heaterTarget = self.frameHandler.heaterTarget[self.handler.heater] + heaterCurrent = self.frameHandler.heaterCurrent[self.handler.heater] + heaterLast = self.frameHandler.heaterLast[self.handler.heater] + + if heaterTarget > 0.0 and heaterCurrent > 0.0: + if (heaterCurrent >= self.effectRate): + if heaterCurrent <= heaterTarget-2: + spark = int((heaterCurrent / heaterTarget) * 80) + brightness = int((heaterCurrent / heaterTarget) * 100) + elif self.effectCutoff > 0: + spark = 0 + else: + spark = 80 + brightness = 100 + elif self.effectRate > 0 and heaterCurrent > 0.0: + if heaterCurrent >= self.effectRate: + spark = int(((heaterCurrent - self.effectRate) + / heaterLast) * 80) + brightness = int(((heaterCurrent - self.effectRate) + / heaterLast) * 100) + + if spark > 0 and heaterTarget != 0: + cooling = int((heaterCurrent / heaterTarget) * 20) + + for h in range(self.heatLen): + c = randint(0, cooling) + self.heatMap[h] -= (self.heatMap[h] - c >= 0 ) * c + + for i in range(self.ledCount - 1, self.heatSource, -1): + d = (self.heatMap[i - 1] + + self.heatMap[i - 2] + + self.heatMap[i - 3] ) / 3 + + self.heatMap[i] = d * (d >= 0) + + if randint(0, 100) < spark: + h = randint(0, self.heatSource) + self.heatMap[h] += brightness + if self.heatMap[h] > 100: + self.heatMap[h] = 100 + + for h in self.heatMap: + frame += self.gradient[int(h)] + + return frame + + else: + return None + + #Progress bar using M73 gcode command + class layerProgress(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerProgress, self).__init__(**kwargs) + + if self.effectRate < 0: + self.effectRate = self.ledCount + + if self.effectCutoff < 0: + self.effectCutoff = self.ledCount + + if self.effectRate == 0: + trailing = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + trailing = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectRate), True)) + trailing.padLeft([0.0]*COLORS, self.ledCount) + + if self.effectCutoff == 0: + leading = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + leading = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectCutoff), False)) + leading.padRight([0.0]*COLORS, self.ledCount) + + gradient = colorArray(COLORS, trailing + self.paletteColors[0] + leading) + gradient.shift(len(trailing), 0) + frames = [gradient[:self.ledCount]] + + for i in range(0, self.ledCount): + gradient.shift(1,1) + frames.append(gradient[:self.ledCount]) + + self.thisFrame.append(colorArray(COLORS, [0.0]*COLORS * self.ledCount)) + for i in range(1, 101): + x = int((i / 101.0) * self.ledCount) + self.thisFrame.append(frames[x]) + + self.frameCount = len(self.thisFrame) + + def nextFrame(self, eventtime): + p = self.frameHandler.printProgress + return self.thisFrame[p] #(p - 1) * (p > 0)] + + class layerHoming(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerHoming, self).__init__(**kwargs) + + self.paletteColors = colorArray(COLORS, self.paletteColors) + + gradientLength = int(self.ledCount) + gradient = colorArray(COLORS, self._gradient(self.paletteColors, + gradientLength)) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame.append(colorArray(COLORS,color*self.ledCount)) + + self.decayTable = self._decayTable(factor=self.effectRate) + self.decayTable.append(0.0) + self.decayLen = len(self.decayTable) + self.counter=self.decayLen-1 + self.coloridx=-1 + self.my_flag={} + for endstop in self.handler.endstops: + self.frameHandler.homing_end_flag[endstop] = 0 + self.my_flag[endstop] = self.frameHandler.homing_end_flag[endstop] + + def nextFrame(self, eventtime): + for endstop in self.handler.endstops: + + if self.my_flag[endstop] != self.frameHandler.homing_end_flag[endstop]: + self.counter = 0 + self.coloridx = (self.coloridx + 1) % len(self.paletteColors) + self.my_flag[endstop] = self.frameHandler.homing_end_flag[endstop] + + frame = [self.decayTable[self.counter] * i for i in self.thisFrame[self.coloridx ]] + if self.counter < self.decayLen-1: + self.counter += 1 + + return frame + class layerSwitchButton(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerSwitchButton, self).__init__(**kwargs) + self.last_state = 0 + self.coloridx = 0 + self.fadeValue = 0.0 + self.paletteColors = colorArray(COLORS, self.paletteColors) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame.append(colorArray(COLORS,color*self.ledCount)) + + def nextFrame(self, eventtime): + if self.handler.button_state > self.last_state: + self.coloridx = (self.coloridx + 1) % len(self.paletteColors) + + self.last_state = self.handler.button_state + + if self.last_state: + if self.effectRate > 0 and self.fadeValue < 1.0: + self.fadeValue += (self.handler.frameRate / self.effectRate) + else: + self.fadeValue = 1.0 + else: + if self.effectCutoff > 0 and self.fadeValue > 0.0: + self.fadeValue -= (self.handler.frameRate / self.effectCutoff) + else: + self.fadeValue = 0.0 + + if self.fadeValue < 0: self.fadeValue = 0 + if self.fadeValue > 1.0: self.fadeValue = 1.0 + return [self.fadeValue * i for i in self.thisFrame[self.coloridx]] + + class layerToggleButton(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerToggleButton, self).__init__(**kwargs) + self.last_state = 0 + self.last_coloridx = 0 + self.coloridx = 0 + self.fadeInValue = 0.0 + self.fadeOutValue = 0.0 + self.active = False + self.paletteColors = colorArray(COLORS, self.paletteColors) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame.append(colorArray(COLORS,color*self.ledCount)) + + def nextFrame(self, eventtime): + if self.handler.button_state > self.last_state: + self.last_coloridx = self.coloridx + self.coloridx = (self.coloridx + 1) % len(self.paletteColors) + self.last_state = self.handler.button_state + self.fadeInValue = 0 + self.fadeOutValue = 1.0 + + self.last_state = self.handler.button_state + + if self.effectRate > 0 and self.fadeInValue < 1.0: + self.fadeInValue += (self.handler.frameRate / self.effectRate) + else: + self.fadeInValue = 1.0 + if self.effectCutoff > 0 and self.fadeOutValue > 0.0: + self.fadeOutValue -= (self.handler.frameRate / self.effectCutoff) + else: + self.fadeOutValue = 0.0 + + if self.fadeInValue < 0: self.fadeInValue = 0 + if self.fadeInValue > 1.0: self.fadeInValue = 1.0 + + if self.fadeOutValue < 0: self.fadeOutValue = 0 + if self.fadeOutValue > 1.0: self.fadeOutValue = 1.0 + + frameIn = [self.fadeInValue * i for i in self.thisFrame[self.coloridx]] + frameOut = [self.fadeOutValue * i for i in self.thisFrame[self.last_coloridx]] + + return [ i + o for i, o in zip(frameIn,frameOut)] + + class layerFlashButton(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerFlashButton, self).__init__(**kwargs) + self.last_state = 0 + self.active = False + self.coloridx = 0 + self.fadeValue = 0.0 + self.paletteColors = colorArray(COLORS, self.paletteColors) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame.append(colorArray(COLORS,color*self.ledCount)) + + def nextFrame(self, eventtime): + + if self.handler.button_state > self.last_state: + self.coloridx = (self.coloridx + 1) % len(self.paletteColors) + self.active = True + + self.last_state=self.handler.button_state + + if self.active: + if self.effectRate > 0 and self.fadeValue < 1.0: + self.fadeValue += (self.handler.frameRate / self.effectRate) + else: + self.fadeValue = 1.0 + if self.fadeValue >= 1.0: + self.fadeValue = 1.0 + self.active = False + else: + if self.effectCutoff > 0 and self.fadeValue > 0.0: + self.fadeValue -= (self.handler.frameRate / self.effectCutoff) + else: + self.fadeValue = 0.0 + + if self.fadeValue <= 0: + self.fadeValue = 0 + + return [self.fadeValue * i for i in self.thisFrame[self.coloridx]] + +# -------------------------------------------------------------------------------------- +# ----------------------- End of klipper_led_effects import ---------------------------- +# -------------------------------------------------------------------------------------- diff --git a/extras/mmu_leds.py b/extras/mmu_leds.py new file mode 100644 index 000000000000..1201dcb52df7 --- /dev/null +++ b/extras/mmu_leds.py @@ -0,0 +1,206 @@ +# Happy Hare MMU Software +# +# Allows for flexible creation of virtual leds chains - one for each of the supported +# segments (exit, entry, status). Entry and exit are indexed by gate number. +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, re + +# Klipper imports +from . import led as klipper_led + +class VirtualMmuLedChain: + def __init__(self, config, unit_name, segment, config_chains): + self.printer = config.get_printer() + self.name = "%s_mmu_%s_leds" % (unit_name, segment) + self.config_chains = config_chains + + # Create temporary config section just to access led helper + led_section = "led %s" % self.name + config.fileconfig.add_section(led_section) + led_config = config.getsection(led_section) + self.led_helper = klipper_led.LEDHelper(led_config, self.update_leds, sum(len(leds) for chain_name, leds in config_chains)) + config.fileconfig.remove_section(led_section) + + # We need to configure the chain now so we can validate + self.leds = [] + for chain_name, leds in self.config_chains: + try: + chain = self.printer.load_object(config, chain_name) + if chain: + for led in leds: + self.leds.append((chain, led)) + except Exception as e: + raise config.error("MMU LED chain '%s' referenced in '%s' cannot be loaded:\n%s" % (chain_name, self.name, str(e))) + + # Register led object with klipper + logging.info("MMU: Created: %s" % led_section) + self.printer.add_object(self.name, self) + + def update_leds(self, led_state, print_time): + chains_to_update = set() + for color, (chain, led) in zip(led_state, self.leds): + chain.led_helper.led_state[led] = color + chains_to_update.add(chain) + for chain in chains_to_update: + chain.led_helper.need_transmit = True + if hasattr(chain.led_helper, '_check_transmit'): + chain.led_helper._check_transmit() # New klipper + else: + chain.led_helper.check_transmit(None) # Older klipper / Kalico + + def get_status(self, eventtime=None): + state = [] + chain_status = {} + for chain, led in self.leds: + if chain not in chain_status: + status = chain.led_helper.get_status(eventtime)['color_data'] + chain_status[chain] = status + state.append(chain_status[chain][led]) + return {"color_data": state} + + +class MmuLeds: + + PER_GATE_SEGMENTS = ['exit', 'entry'] + SEGMENTS = PER_GATE_SEGMENTS + ['status', 'logo'] + + def __init__(self, config, *args): + if len(args) < 2: + raise config.error("[%s] cannot be instantiated directly. It must be loaded by [mmu_unit]" % config.get_name()) + self.mmu_machine, self.mmu_unit, self.first_gate, self.num_gates = args + self.name = config.get_name().split()[-1] + self.printer = config.get_printer() + self.frame_rate = config.getint('frame_rate', 24) + + # Create virtual led chains + self.virtual_chains = {} + for segment in self.SEGMENTS: + name = "%s_leds" % segment + config_chains = [self.parse_chain(line) for line in config.get(name, '').split('\n') if line.strip()] + self.virtual_chains[segment] = VirtualMmuLedChain(config, "unit%d" % self.mmu_unit.unit_index, segment, config_chains) + + num_leds = len(self.virtual_chains[segment].leds) + if segment in self.PER_GATE_SEGMENTS and num_leds > 0 and num_leds % self.num_gates: + raise config.error("Number of MMU '%s' LEDs (%d) cannot be spread over num_gates (%d)" % (segment, num_leds, self.num_gates)) + + # Check for LED chain overlap or unavailable LEDs + used = {} + for segment in self.SEGMENTS: + for led in self.virtual_chains[segment].leds: + obj, index = led + if index >= obj.led_helper.led_count: + raise config.error("MMU LED (with index %d) on segment %s isn't available" % (index + 1, segment)) + if led in used: + raise config.error("Same MMU LED (with index %d) used more than one segment: %s and %s" % (index + 1, used[led], segment)) + else: + used[led] = segment + + # Read default effects for each segment and other options + self.enabled = config.get('enabled', True) + self.animation = config.get('animation', True) + self.exit_effect = config.get('exit_effect', 'gate_status') + self.entry_effect = config.get('entry_effect', 'filament_color') + self.status_effect = config.get('status_effect', 'filament_color') + self.logo_effect = MmuLeds.string_to_rgb(config.get('logo_effect', '(0,0,0.3)')) + self.white_light = MmuLeds.string_to_rgb(config.get('white_light', '(1,1,1)')) + self.black_light = MmuLeds.string_to_rgb(config.get('black_light', '(0.01,0,0.02)')) + self.empty_light = MmuLeds.string_to_rgb(config.get('empty_light', '(0,0,0)')) + + # Read operation to effect mappings + self.effects = {} + self.effect_rgb = {} + effect_keys = [ + 'effect_loading', + 'effect_loading_extruder', + 'effect_unloading', + 'effect_unloading_extruder', + 'effect_heating', + 'effect_selecting', + 'effect_checking', + 'effect_initialized', + 'effect_error', + 'effect_complete', + 'effect_gate_selected', + 'effect_gate_available', + 'effect_gate_unknown', + 'effect_gate_empty', + 'effect_gate_available_sel', + 'effect_gate_unknown_sel', + 'effect_gate_empty_sel' + ] + for key in effect_keys: + parts = [part.strip() for part in config.get(key, '').split(",", 1)] + effect = parts[0] + rgb_string = parts[1] if len(parts) == 2 else config.get('empty_light', '(0,0,0)') + operation = key[len('effect_'):] + self.effects[operation] = effect + self.effect_rgb[effect] = MmuLeds.string_to_rgb(rgb_string) + self.effect_rgb[''] = (0,0,0) + + def parse_chain(self, chain): + chain = chain.strip() + leds=[] + parms = [parameter.strip() for parameter in chain.split() if parameter.strip()] + if parms: + chain_name = parms[0].replace(':',' ') + led_indices = ''.join(parms[1:]).strip('()').split(',') + for led in led_indices: + if led: + if '-' in led: + start, stop = map(int,led.split('-')) + if stop == start: + ledList = [start-1] + elif stop > start: + ledList = list(range(start-1, stop)) + else: + ledList = list(reversed(range(stop-1, start))) + for i in ledList: + leds.append(int(i)) + else: + for i in led.split(','): + leds.append(int(i)-1) + return chain_name, leds + else: + return None, None + + def get_effect(self, operation): + return self.effects.get(operation, '') + + def get_rgb_for_effect(self, effect): + return self.effect_rgb[effect] + + def get_status(self, eventtime=None): + status = {segment: len(self.virtual_chains[segment].leds) for segment in self.SEGMENTS} + status.update({ + 'enabled': self.enabled, + 'animation': self.animation, + 'exit_effect': self.exit_effect, + 'entry_effect': self.entry_effect, + 'status_effect': self.status_effect, + 'logo_effect': self.logo_effect, + 'num_gates': self.num_gates, + }) + return status + + @staticmethod + def string_to_rgb(rgb_string): + if not isinstance(rgb_string, tuple): + rgb = re.sub(r"[\"'()]", '', rgb_string) # Clean up strings + rgb = tuple(float(x) for x in rgb.split(',')) + else: + rgb = rgb_string + if len(rgb) != 3: + raise ValueError("%s is not a valid rgb tuple" % str(rgb_string)) + return rgb + +def load_config_prefix(config): + return MmuLeds(config) diff --git a/extras/mmu_machine.py b/extras/mmu_machine.py new file mode 100644 index 000000000000..6564a0df30e9 --- /dev/null +++ b/extras/mmu_machine.py @@ -0,0 +1,1357 @@ +# Happy Hare MMU Software +# +# Definition of basic physical characteristics of MMU (including type/style) +# - allows for hardware configuration validation +# +# Implementation of "MMU Toolhead" to allow for: +# - "drip" homing and movement without pauses +# - bi-directional syncing of extruder to gear rail or gear rail to extruder +# - extra "standby" endstops +# - extruder endstops and extruder only homing +# - switchable drive steppers on rails +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Based on code by Kevin O'Connor +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, importlib, math, os, time, re + +# Klipper imports +import stepper, chelper, toolhead +from kinematics.extruder import PrinterExtruder, DummyExtruder, ExtruderStepper +from .homing import Homing, HomingMove + +from . import mmu_leds + +# For toolhead synchronization +EPS = 1e-6 # ~1 microsecond safety +SYNC_AIR_GAP = 0.001 # Sync time air gap +MOVE_HISTORY_EXPIRE = 30.0 # From motion_queuing.py + +# TMC chips to search for +TMC_CHIPS = ["tmc2209", "tmc2130", "tmc2208", "tmc2660", "tmc5160", "tmc2240"] + +# Stepper config sections +SELECTOR_STEPPER_CONFIG = "stepper_mmu_selector" # Optional +GEAR_STEPPER_CONFIG = "stepper_mmu_gear" + +SHAREABLE_STEPPER_PARAMS = ['rotation_distance', 'gear_ratio', 'microsteps', 'full_steps_per_rotation'] +OTHER_STEPPER_PARAMS = ['step_pin', 'dir_pin', 'enable_pin', 'endstop_pin', 'rotation_distance', 'pressure_advance', 'pressure_advance_smooth_time'] + +SHAREABLE_TMC_PARAMS = ['run_current', 'hold_current', 'interpolate', 'sense_resistor', 'stealthchop_threshold'] + +# Vendor MMU's supported +VENDOR_ERCF = "ERCF" +VENDOR_TRADRACK = "Tradrack" +VENDOR_PRUSA = "Prusa" +VENDOR_ANGRY_BEAVER = "AngryBeaver" +VENDOR_BOX_TURTLE = "BoxTurtle" +VENDOR_NIGHT_OWL = "NightOwl" +VENDOR_3MS = "3MS" +VENDOR_3D_CHAMELEON = "3DChameleon" +VENDOR_PICO_MMU = "PicoMMU" +VENDOR_QUATTRO_BOX = "QuattroBox" +VENDOR_MMX = "MMX" +VENDOR_VVD = "VVD" +VENDOR_KMS = "KMS" +VENDOR_EMU = "EMU" +VENDOR_OTHER = "Other" + +UNIT_ALT_DISPLAY_NAMES = { + VENDOR_ANGRY_BEAVER: "Angry Beaver", + VENDOR_BOX_TURTLE: "Box Turtle", + VENDOR_NIGHT_OWL: "Night Owl", + VENDOR_VVD: "BTT VVD", +} + +VENDORS = [VENDOR_ERCF, VENDOR_TRADRACK, VENDOR_PRUSA, VENDOR_ANGRY_BEAVER, VENDOR_BOX_TURTLE, VENDOR_NIGHT_OWL, VENDOR_3MS, VENDOR_3D_CHAMELEON, VENDOR_PICO_MMU, VENDOR_QUATTRO_BOX, VENDOR_MMX, VENDOR_VVD, VENDOR_KMS, VENDOR_EMU, VENDOR_OTHER] + + +# Define type/style of MMU and expand configuration for convenience. Validate hardware configuration +class MmuMachine: + + def __init__(self, config): + # Essential information for validation and setup + self.config = config + self.printer = config.get_printer() + self.gate_counts = list(config.getintlist('num_gates', [])) + self.num_units = len(self.gate_counts) + if self.num_units < 1: + raise config.error("Invalid or missing num_gates parameter in section [mmu_machine]") + self.num_gates = sum(self.gate_counts) + self.mmu_vendor = config.getchoice('mmu_vendor', {o: o for o in VENDORS}, VENDOR_OTHER) + self.mmu_version_string = config.get('mmu_version', "1.0") + version = re.sub("[^0-9.]", "", self.mmu_version_string) or "1.0" + try: + self.mmu_version = float(version) + except ValueError: + raise config.error("Invalid version parameter") + + # Hack to bring some v4 functionality into v3 + # vvv + + # Create a minimal fake MmuUnit object + self.units = [] # Unit by index + self.unit_by_gate = [] # Quick unit lookup by gate + next_gate = 0 + for i in range(self.num_units): + unit = MmuUnit("unit%d" % i, i, next_gate, self.gate_counts[i]) + self.units.append(unit) + self.unit_by_gate[next_gate:next_gate + self.gate_counts[i]] = [unit] * self.gate_counts[i] + next_gate += self.gate_counts[i] + + orig_section = 'mmu_leds' + if config.has_section(orig_section): + raise config.error("v3.4 requires update of [mmu_leds] section for multiple mmu units") + + # Load optional mmu_leds module for each mmu unit in v4 style + for unit in self.units: + section = 'mmu_leds %s' % unit.name + if config.has_section(section): + c = config.getsection(section) + unit.leds = mmu_leds.MmuLeds(c, self, unit, unit.first_gate, unit.num_gates) + logging.info("MMU: Created: %s" % c.get_name()) + self.printer.add_object(c.get_name(), unit.leds) # Register mmu_leds to stop it being loaded by klipper + + # ^^^ + # Hack to bring some v4 functionality into v3 + + # MMU design for control purposes can be broken down into the following choices: + # - Selector type or no (Virtual) selector + # - Does each gate of the MMU have different BMG drive gears (or similar). I.e. drive rotation distance is variable + # - Does each gate of the MMU have different bowden path + # - Does design require "bowden move" (i.e. non zero length bowden) + # - Is filament always gripped by MMU + # - Does selector mechanism still allow selection of gates when filament is loaded (implied for Multigear designs) + # - Does design has a filament bypass + selector_type = 'LinearServoSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 # Will allow mmu_gate sensor and extruder sensor to share the same pin + filament_always_gripped = 0 # Whether MMU design has ability to release filament (overrides gear/extruder syncing) + can_crossload = 0 # Design allows preloading/eject in one gate while another gate is loaded (also requires post_gear sensor) + has_bypass = 0 # Whether MMU design has bypass gate (also has to be calibrated on type-A designs with LinearServoSelector) + + if self.mmu_vendor == VENDOR_ERCF: + selector_type = 'LinearServoSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 0 + has_bypass = 1 + + elif self.mmu_vendor == VENDOR_TRADRACK: + selector_type = 'LinearServoSelector' + variable_rotation_distances = 0 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 0 + has_bypass = 1 + + elif self.mmu_vendor == VENDOR_PRUSA: + raise config.error("Prusa MMU is not yet supported") + + elif self.mmu_vendor == VENDOR_ANGRY_BEAVER: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 0 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_BOX_TURTLE: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_NIGHT_OWL: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_3MS: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 0 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_3D_CHAMELEON: + selector_type = 'RotarySelector' + variable_rotation_distances = 0 + variable_bowden_lengths = 1 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_PICO_MMU: + selector_type = 'ServoSelector' + variable_rotation_distances = 0 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_QUATTRO_BOX: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_MMX: + selector_type = 'ServoSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_VVD: + selector_type = 'IndexedSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_KMS: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_EMU: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 1 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 1 + + # Still allow MMU design attributes to be altered or set for custom MMU + self.selector_type = config.getchoice('selector_type', {o: o for o in ['VirtualSelector', 'LinearSelector', 'LinearServoSelector', 'LinearMultiGearSelector', 'RotarySelector', 'MacroSelector', 'ServoSelector', 'IndexedSelector']}, selector_type) + self.variable_rotation_distances = bool(config.getint('variable_rotation_distances', variable_rotation_distances)) + self.variable_bowden_lengths = bool(config.getint('variable_bowden_lengths', variable_bowden_lengths)) + self.require_bowden_move = bool(config.getint('require_bowden_move', require_bowden_move)) + self.filament_always_gripped = bool(config.getint('filament_always_gripped', filament_always_gripped)) + self.can_crossload = bool(config.getint('can_crossload', can_crossload)) + self.has_bypass = bool(config.getint('has_bypass', has_bypass)) + + # Other attributes + self.display_name = config.get('display_name', UNIT_ALT_DISPLAY_NAMES.get(self.mmu_vendor, self.mmu_vendor)) + + self.environment_sensor = config.get('environment_sensor', '') + self.filament_heater = config.get('filament_heater', '') + + # Special handling for EMU MMU's that can have a heater and environment sensor per gate + self.environment_sensors = list(config.getlist('environment_sensors', [])) + if len(self.environment_sensors) not in [0, self.num_gates]: + raise config.error("'environment_sensors' must be empty or a comma separated list of 'num_gate' elements") + self.filament_heaters = list(config.getlist('filament_heaters', [])) + if len(self.filament_heaters) not in [0, self.num_gates]: + raise config.error("'filament_heaters' must be empty, a single value or a comma separated list of 'num_gate' elements") + self.max_concurrent_heaters = config.getint('max_concurrent_heaters', self.num_gates) + + # Check mutually exclusive environment options + if (self.environment_sensor or self.filament_heater) and (self.environment_sensors or self.filament_heaters): + raise config.error("Can't configure single multiple MMU heaters/environment sensors") + + # Check all heater and environment sensor objects are valid + for obj_name in self.filament_heaters + [self.filament_heater] + self.environment_sensors + [self.environment_sensor]: + if obj_name: + try: + # If we can't load heater/sensor then it is misconfigured + obj = self.printer.load_object(config, obj_name) + except Exception as e: + raise config.error("Object '%s' could not be loaded as a valid heater or environment sensor in [mmu_machine]\nError: %s" % (obj_name, str(e))) + + # By default HH uses a modified homing extruder. Because this might have unknown consequences on certain + # set-ups it can be disabled. If disabled, homing moves will still work, but the delay in mcu to mcu comms + # can lead to several mm of error depending on speed. Also homing of just the extruder is not possible. + self.homing_extruder = bool(config.getint('homing_extruder', 1, minval=0, maxval=1)) + + # Expand config to allow lazy (incomplete) repetitious gear configuration for type-B MMU's + self.multigear = False + + # Find the TMC controller for base stepper so we can fill in missing config for other matching steppers + base_tmc_chip = base_tmc_section = None + for chip in TMC_CHIPS: + base_tmc_section = '%s %s' % (chip, GEAR_STEPPER_CONFIG) + if config.has_section(base_tmc_section): + base_tmc_chip = chip + break + + last_gear = 24 + for i in range(1, last_gear): # Don't allow "_0" or it is confusing with unprefixed initial stepper + section = "%s_%d" % (GEAR_STEPPER_CONFIG, i) + if not config.has_section(section): + last_gear = i + break + + self.multigear = True + + # Share stepper config section with additional steppers + stepper_section = "%s_%d" % (GEAR_STEPPER_CONFIG, i) + for key in SHAREABLE_STEPPER_PARAMS: + if not config.fileconfig.has_option(stepper_section, key) and config.fileconfig.has_option(GEAR_STEPPER_CONFIG, key): + base_value = config.fileconfig.get(GEAR_STEPPER_CONFIG, key) + if base_value: + logging.info("MMU: Sharing gear stepper config %s=%s with %s" % (key, base_value, stepper_section)) + config.fileconfig.set(stepper_section, key, base_value) + + # IF TMC controller for this additional stepper matches the base we can fill in missing TMC config + if base_tmc_chip: + tmc_section = '%s %s_%d' % (base_tmc_chip, GEAR_STEPPER_CONFIG, i) + if config.has_section(tmc_section): + for key in SHAREABLE_TMC_PARAMS: + if config.fileconfig.has_option(base_tmc_section, key) and not config.fileconfig.has_option(tmc_section, key): + base_value = config.fileconfig.get(base_tmc_section, key) + if base_value: + logging.info("MMU: Sharing gear tmc config %s=%s with %s" % (key, base_value, tmc_section)) + config.fileconfig.set(tmc_section, key, base_value) + + # H/W validation checks + if self.multigear and last_gear != self.num_gates: + raise config.error("MMU is configured with %d gates but %d gear stepper configurations were found" % (self.num_gates, last_gear)) + + # TODO add more h/w validation here based on num_gates & vendor, virtual selector, etc + # TODO would allow for easier to understand error messages for conflicting or missing + # TODO hardware definitions. + + # TODO: Temp until restructured to allow multiple MMU's of different types. + gate_count = 0 + self.unit_status = {} + for i, unit in enumerate(self.gate_counts): + unit_info = {} + unit_info['name'] = self.display_name + unit_info['vendor'] = self.mmu_vendor + unit_info['version'] = self.mmu_version_string + unit_info['num_gates'] = unit + unit_info['first_gate'] = gate_count + unit_info['selector_type'] = self.selector_type + unit_info['variable_rotation_distances'] = self.variable_rotation_distances + unit_info['variable_bowden_lengths'] = self.variable_bowden_lengths + unit_info['require_bowden_move'] = self.require_bowden_move + unit_info['filament_always_gripped'] = self.filament_always_gripped + unit_info['can_crossload'] = self.can_crossload + unit_info['has_bypass'] = self.has_bypass + unit_info['multi_gear'] = self.multigear + if self.environment_sensor or self.filament_heater: + # Single heater/sensor + unit_info['environment_sensor'] = self.environment_sensor + unit_info['filament_heater'] = self.filament_heater + elif self.environment_sensors or self.filament_heaters: + # Per-gate heater/sensors + unit_info['environment_sensors'] = self.environment_sensors[gate_count:gate_count + unit] + unit_info['filament_heaters'] = self.filament_heaters[gate_count:gate_count + unit] + gate_count += unit + self.unit_status["unit_%d" % i] = unit_info + self.unit_status['num_units'] = len(self.gate_counts) + + def get_mmu_unit_by_index(self, index): # Hack to allow some v4 functionality into the v3 line + if index >= 0 and index < self.num_units: + return self.units[index] + return None + + def get_mmu_unit_by_gate(self, gate): # Hack to allow some v4 functionality into the v3 line + if gate >= 0 and gate < self.num_gates: + return self.unit_by_gate[gate] + return None + + def get_status(self, eventtime): + return self.unit_status + +# This is a Hack to allow some v4 functionality into the v3 line. Skeletal MmuUnit object +class MmuUnit: + def __init__(self, name, unit_index, first_gate, num_gates): + self.name = name + self.unit_index = unit_index + self.first_gate = first_gate + self.num_gates = num_gates + self.leds = None + + def manages_gate(self, gate): + return self.first_gate <= gate < self.first_gate + self.num_gates + + +# Main code to track events (and their timing) on the MMU Machine implemented as additional "toolhead" +# (code pulled from toolhead.py) +class MmuToolHead(toolhead.ToolHead, object): + + # Gear/Extruder synchronization modes (None = unsynced) + EXTRUDER_SYNCED_TO_GEAR = 1 # Aka 'gear+extruder' + EXTRUDER_ONLY_ON_GEAR = 2 # Aka 'extruder' (only) + GEAR_SYNCED_TO_EXTRUDER = 3 # Aka 'extruder+gear' + GEAR_ONLY = 4 # Aka 'gear' (same state as unsync() but with protective wait) + + def __init__(self, config, mmu): + self.mmu = mmu + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + + self.all_mcus = [m for n, m in self.printer.lookup_objects(module='mcu')] # Older Klipper + self.mcu = self.all_mcus[0] # Older Klipper + #self.mcu = self.printer.lookup_object('mcu') # Klipper approx >= 0.13.0-328 (safer lookup, guarantee's primary mcu or config error) + + self._resync_lock = self.reactor.mutex() + + if hasattr(toolhead, 'BUFFER_TIME_HIGH'): + time_high = toolhead.BUFFER_TIME_HIGH + else: + # Backward compatibility for older klipper, like on Sovol or Creality K1 series printers + # On Creality K1, these attributes are expected to exist in any Toolhead + self.buffer_time_low = config.getfloat('buffer_time_low', 1.000, above=0.) + self.buffer_time_high = config.getfloat('buffer_time_high', 2.000, above=self.buffer_time_low) + self.buffer_time_start = config.getfloat('buffer_time_start', 0.250, above=0.) + self.move_flush_time = config.getfloat('move_flush_time', 0.050, above=0.) + self.last_kin_flush_time = self.force_flush_time = self.last_kin_move_time = 0. + time_high = self.buffer_time_high + + if hasattr(toolhead, 'LookAheadQueue'): + try: + self.lookahead = toolhead.LookAheadQueue(self) # Klipper < 3.13.0-46 + except: + self.lookahead = toolhead.LookAheadQueue() # >= Klipper 3.13.0-46 + self.lookahead.set_flush_time(time_high) + else: + # Klipper backward compatibility + self.move_queue = toolhead.MoveQueue(self) + self.move_queue.set_flush_time(time_high) + self.commanded_pos = [0., 0., 0., 0.] + + # For Creality Ender3v3 series (custom klipper) + self.gap_auto_comp = None + + # MMU velocity and acceleration control + self.gear_max_velocity = config.getfloat('gear_max_velocity', 300, above=0.) + self.gear_max_accel = config.getfloat('gear_max_accel', 500, above=0.) + self.selector_max_velocity = config.getfloat('selector_max_velocity', 250, above=0.) + self.selector_max_accel = config.getfloat('selector_max_accel', 1500, above=0.) + + self.max_velocity = max(self.selector_max_velocity, self.gear_max_velocity) + self.max_accel = max(self.selector_max_accel, self.gear_max_accel) + + min_cruise_ratio = 0.5 + if config.getfloat('minimum_cruise_ratio', None) is None: + req_accel_to_decel = config.getfloat('max_accel_to_decel', None, above=0.) + if req_accel_to_decel is not None: + config.deprecate('max_accel_to_decel') + min_cruise_ratio = 1. - min(1., (req_accel_to_decel / self.max_accel)) + self.min_cruise_ratio = config.getfloat('minimum_cruise_ratio', min_cruise_ratio, below=1., minval=0.) + self.square_corner_velocity = config.getfloat('square_corner_velocity', 5., minval=0.) + self.junction_deviation = self.max_accel_to_decel = 0. + self.requested_accel_to_decel = self.min_cruise_ratio * self.max_accel # Backward klipper compatibility 31de734d193d + self._calc_junction_deviation() + + # This is now a big switch that gates changes to the Toolhead in Klipper + self.motion_queuing = self.printer.load_object(config, 'motion_queuing', None) + + # Input stall detection + self.check_stall_time = 0. + self.print_stall = 0 + # Input pause tracking + self.can_pause = True + if self.mcu.is_fileoutput(): + self.can_pause = False + self.need_check_pause = -1. + # Print time tracking + self.print_time = 0. + self.special_queuing_state = "NeedPrime" + self.priming_timer = None + self.drip_completion = None # TODO No longer part of Klipper >v0.13.0-46 + + if not self.motion_queuing: + # Flush tracking + self.flush_timer = self.reactor.register_timer(self._flush_handler) + self.do_kick_flush_timer = True + self.last_flush_time = self.last_sg_flush_time = self.min_restart_time = 0. # last_sg_flush_time deprecated + self.need_flush_time = self.step_gen_time = self.clear_history_time = 0. + # Kinematic step generation scan window time tracking + self.kin_flush_delay = toolhead.SDS_CHECK_TIME # Happy Hare: Use base class + self.kin_flush_times = [] + + if self.motion_queuing: + ffi_main, ffi_lib = chelper.get_ffi() + self.trapq_finalize_moves = ffi_lib.trapq_finalize_moves # Want my own binding so I know its available + + # Setup for generating moves + try: + self.motion_queuing.register_flush_callback(self._handle_step_flush, can_add_trapq=True) # Latest klipper >= v0.13.0-267 + except AttributeError: + self.motion_queuing.setup_lookahead_flush_callback(self._check_flush_lookahead) + + self.trapq = self.motion_queuing.allocate_trapq() + self.trapq_append = self.motion_queuing.lookup_trapq_append() + else: + # Setup iterative solver + ffi_main, ffi_lib = chelper.get_ffi() + self.trapq = ffi_main.gc(ffi_lib.trapq_alloc(), ffi_lib.trapq_free) + self.trapq_append = ffi_lib.trapq_append + self.trapq_finalize_moves = ffi_lib.trapq_finalize_moves + # Motion flushing + self.step_generators = [] + self.flush_trapqs = [self.trapq] + + # Create kinematics class + gcode = self.printer.lookup_object('gcode') + self.Coord = gcode.Coord + self.extruder = DummyExtruder(self.printer) + self.extra_axes = [self.extruder] + + self.printer.register_event_handler("klippy:shutdown", self._handle_shutdown) + + # Create MMU kinematics + try: + self.kin = MmuKinematics(self, config) + steppers = list(self.kin.rails[1].get_steppers()) + self.all_gear_rail_steppers = steppers.copy() + self.selected_gear_steppers = steppers.copy() + except config.error: + raise + except self.printer.lookup_object('pins').error: + raise + except: + msg = "Error loading MMU kinematics" + logging.exception("MMU: %s" % msg) + raise config.error(msg) + + self.mmu_machine = self.printer.lookup_object("mmu_machine") + self.mmu_extruder_stepper = None + if self.mmu_machine.homing_extruder: + # Create MmuExtruderStepper for later insertion into PrinterExtruder on Toolhead (on klippy:connect) + self.mmu_extruder_stepper = MmuExtruderStepper(config.getsection('extruder'), self.kin.rails[1]) # Only first extruder is handled + + # Nullify original extruder stepper definition so Klipper doesn't try to create it again. Restore in handle_connect() so config lookups succeed + self.old_ext_options = {} + self.config = config + for i in SHAREABLE_STEPPER_PARAMS + OTHER_STEPPER_PARAMS: + if config.fileconfig.has_option('extruder', i): + self.old_ext_options[i] = config.fileconfig.get('extruder', i) + config.fileconfig.remove_option('extruder', i) + + self.printer.register_event_handler('klippy:connect', self.handle_connect) + + # Add useful debugging command + gcode.register_command('_MMU_DUMP_TOOLHEAD', self.cmd_DUMP_RAILS, desc=self.cmd_DUMP_RAILS_help) + + # Bi-directional sync management of gear(s) and extruder(s) + self.mmu_toolhead = self # Make it easier to read code and distinquish printer_toolhead from mmu_toolhead + self.sync_mode = None + + def handle_connect(self): + self.printer_toolhead = self.printer.lookup_object('toolhead') + + printer_extruder = self.printer_toolhead.get_extruder() + if self.mmu_machine.homing_extruder: + # Restore original extruder options in case user macros reference them + for key, value in self.old_ext_options.items(): + self.config.fileconfig.set('extruder', key, value) + + # Now we can switch in homing MmuExtruderStepper + printer_extruder.extruder_stepper = self.mmu_extruder_stepper + self.mmu_extruder_stepper.stepper.set_trapq(printer_extruder.get_trapq()) + else: + self.mmu_extruder_stepper = printer_extruder.extruder_stepper + + # Ensure the correct number of axes for convenience - MMU only has two + # Also, handle case when gear rail is synced to extruder + def set_position(self, newpos, homing_axes=()): + for _ in range(4 - len(newpos)): + newpos.append(0.) + super(MmuToolHead, self).set_position(newpos, homing_axes) + + def get_selector_limits(self): + return self.selector_max_velocity, self.selector_max_accel + + def get_gear_limits(self): + return self.gear_max_velocity, self.gear_max_accel + + # Gear/Extruder synchronization and stepper swapping management... + + def select_gear_stepper(self, gate): + if not self.mmu_machine.multigear: return + with self._resync_lock: + if gate == 0: + self._reconfigure_rail_no_lock([GEAR_STEPPER_CONFIG]) + elif gate > 0: + self._reconfigure_rail_no_lock(["%s_%d" % (GEAR_STEPPER_CONFIG, gate)]) + else: + self._reconfigure_rail_no_lock(None) + + def _reconfigure_rail_no_lock(self, selected): + m_th = self.mmu_toolhead + gear_rail = m_th.get_kinematics().rails[1] + mmu_trapq = m_th.get_trapq() + + prev_sync_mode = self.sync_mode + if self.sync_mode not in [self.GEAR_ONLY, None]: + self._resync_no_lock(None) # Unsync first + else: + ths = [self.printer_toolhead, self.mmu_toolhead] + t_cut = self._quiesce_align_get_tcut(ths, full=False) + + # Activate only the desired gear steppers + pos = [0., self.mmu_toolhead.get_position()[1], 0.] + gear_rail.steppers = [] + + self.selected_gear_steppers = [] + for s in self.all_gear_rail_steppers: + if selected and s.get_name() in selected: + self.selected_gear_steppers.append(s) + gear_rail.steppers.append(s) + self._register(m_th, s, trapq=mmu_trapq) # s.set_trapq(mmu_trapq) + else: + # Cripple unused/unwanted gear steppers + self._unregister(m_th, s) # s.set_trapq(None) + + if selected: + if not gear_rail.steppers: + raise self.printer.command_error("None of these '%s' gear steppers where found!" % selected) + gear_rail.set_position(pos) + + # Restore previous syncing mode + # (Not needed in practice) + # if prev_sync_mode != self.sync_mode: + # self._resync_no_lock(prev_sync_mode) + + # Register stepper step generator with desired toolhead and add toolhead's trapq + def _register(self, toolhead, stepper, trapq=None): + trapq = trapq or toolhead.get_trapq() + stepper.set_trapq(trapq) # Restore movement + if not self.motion_queuing: + # klipper 0.13.0 <= 195 we also register step generators from mmu_toolhead + if stepper.generate_steps not in self.mmu_toolhead.step_generators: + if hasattr(toolhead, 'register_step_generator'): + toolhead.register_step_generator(stepper.generate_steps) + else: + toolhead.step_generators.append(stepper.generate_steps) + + # Unregister stepper step generator with desired toolhead and remove from trapq + def _unregister(self, toolhead, stepper): + stepper.set_trapq(None) # Cripple movement + if not self.motion_queuing: + # klipper 0.13.0 <= 195 we also unregister step generators from mmu_toolhead + if stepper.generate_steps in self.mmu_toolhead.step_generators: + if hasattr(toolhead, 'unregister_step_generator'): + toolhead.unregister_step_generator(stepper.generate_steps) + else: + # Why did Kalico remove this function? + toolhead.step_generators.remove(stepper.generate_steps) + + def quiesce(self, full_quiesce=True): + with self._resync_lock: + ths = [self.printer_toolhead, self.mmu_toolhead] + t_cut = self._quiesce_align_get_tcut(ths, full=full_quiesce, wait=full_quiesce) + + # Drain required toolheads, align to a common future time, materialize it, + # and return a strict fence time t_cut. 'wait' shouldn't be needed but + # is helpful when debugging + def _quiesce_align_get_tcut(self, ths, full=False, wait=False): + start = time.time() + # Drain whatever is already planned + for th in ths: + th.flush_step_generation() + if full: + for th in ths: + th.wait_moves() + for th in ths: + th.flush_step_generation() + + # Align planners to a common future time + last_times = [th.get_last_move_time() for th in ths] + t_future = max(last_times) + SYNC_AIR_GAP + for th, lm in zip(ths, last_times): + dt = t_future - lm + if dt > 0.0: + th.dwell(dt) + + # Materialize the air gap before choosing the fence + for th in ths: + th.flush_step_generation() + + # Optional wait and flush to aid debugging + if wait: + for th in ths: + th.wait_moves() + for th in ths: + th.flush_step_generation() + + self.mmu.log_stepper("_quiesce_align_get_tcut(full=%s, wait=%s) Elapsed:%.6f" % (full, wait, time.time() - start)) + return t_future + EPS + + def is_synced(self): + return self.sync_mode is not None + + # Is extruder stepper synced to gear rail (general MMU synced movement) + def is_extruder_synced_to_gear(self): + return self.sync_mode in [self.EXTRUDER_SYNCED_TO_GEAR, self.EXTRUDER_ONLY_ON_GEAR] + + # Is gear rail synced to extruder (for in print syncing) + def is_gear_synced_to_extruder(self): + return self.sync_mode == self.GEAR_SYNCED_TO_EXTRUDER + + def sync(self, new_sync_mode): + with self._resync_lock: + return self._resync_no_lock(new_sync_mode) + + def unsync(self): + with self._resync_lock: + return self._resync_no_lock(None) + + # This resets the state of mmu/extruder synchronization carefully avoiding stepcompress issues. + # It is quite tricky to do efficiently without timing windows. The goal is to do the minimum + # of waiting and only drain everything IF the extruder is being synced to gear rail or + # gear rail synced to the extruder + def _resync_no_lock(self, new_sync_mode): + + if new_sync_mode == self.sync_mode: + return new_sync_mode + + prev_sync_mode = self.sync_mode + ffi_main, ffi_lib = chelper.get_ffi() + + def _finalize_if_valid(tq, t): + if tq is not None and tq != ffi_main.NULL: + self.trapq_finalize_moves(tq, t, t - MOVE_HISTORY_EXPIRE) + + self.mmu.log_stepper("resync(%s --> %s)" % (self.sync_mode_to_string(self.sync_mode), self.sync_mode_to_string(new_sync_mode))) + + # ---------------- Phase A: FENCE if messing with extruder ----------- + + user_control_states = [self.GEAR_SYNCED_TO_EXTRUDER, None] + relocated_extruder_states = [self.EXTRUDER_SYNCED_TO_GEAR, self.EXTRUDER_ONLY_ON_GEAR] + mmu_control_states = relocated_extruder_states + [self.GEAR_ONLY] + full_quiesce = ( + (new_sync_mode in user_control_states and self.sync_mode in mmu_control_states) or + (new_sync_mode in mmu_control_states and self.sync_mode in user_control_states) + ) + + ths = [self.printer_toolhead, self.mmu_toolhead] + gear_rail = self.mmu_toolhead.get_kinematics().rails[1] + t0 = self._quiesce_align_get_tcut(ths, full=full_quiesce, wait=full_quiesce) # Build cutover fence + + # UNSYNC current mode (if any) to base state at t0 + if self.sync_mode not in [self.GEAR_ONLY, None]: + + # ---------------- Phase B: UNSYNC current mode at t0 ---------------- + + self.mmu.log_stepper("unsync(%s)" % ("mode=gear_only" if new_sync_mode == self.GEAR_ONLY else "")) + + # Figure out who is CURRENTLY driving (old owner) and who will receive (new owner) + if self.sync_mode in [self.EXTRUDER_SYNCED_TO_GEAR, self.EXTRUDER_ONLY_ON_GEAR]: + driving_toolhead = self.mmu_toolhead # OLD owner (mmu/gear) + following_toolhead = self.printer_toolhead # NEW owner (printer/extruder) + following_steppers = [following_toolhead.get_extruder().extruder_stepper.stepper] + old_trapq = driving_toolhead.get_trapq() # trapq we’re finalizing + new_trapq = self._prev_trapq # trapq saved during sync() + pos = [following_toolhead.get_position()[3], 0., 0.] + + # Always remove the extruder stepper we appended to the gear rail (will always be at end of list) + gear_rail.steppers = gear_rail.steppers[:-len(following_steppers)] + + elif self.sync_mode == self.GEAR_SYNCED_TO_EXTRUDER: + driving_toolhead = self.printer_toolhead # OLD owner (printer/extruder) + following_toolhead = self.mmu_toolhead # NEW owner (mmu/gear) + # All gear-rail steppers were following the extruder + following_steppers = following_toolhead.get_kinematics().rails[1].get_steppers() + old_trapq = driving_toolhead.get_extruder().get_trapq() # trapq we’re finalizing + new_trapq = self._prev_trapq # trapq saved during sync() + pos = [0., following_toolhead.get_position()[1], 0.] + + # Hard close the old trapq up to the fence (don’t wipe) + # Anything <= t0 moves to history so it can’t be emitted later. + _finalize_if_valid(old_trapq, t0) + + # Rebind steppers back to the NEW owner’s trapq and restore kinematics + for i, s in enumerate(following_steppers): + s.set_stepper_kinematics(self._prev_sk[i]) + s.set_rotation_distance(self._prev_rd[i]) + # s.set_trapq(new_trapq) # Attach to NEW owner on the pre-saved trapq + self._unregister(driving_toolhead, s) # Detach from OLD owner… + self._register(following_toolhead, s, trapq=new_trapq) # …then attach to NEW owner on the pre-saved trapq + # Coordinate-only seed (timing will be enforced by advancing the receiver) + s.set_position(pos) + + # Restore previously disabled gear steppers (after the extruder is back on printer toolhead) + if self.sync_mode == self.EXTRUDER_ONLY_ON_GEAR: + for s in self.selected_gear_steppers: + self._register(self.mmu_toolhead, s) # s.set_trapq(self.mmu_toolhead.get_trapq()) + s.set_position([0., self.mmu_toolhead.get_position()[1], 0.]) + + ## Required for klipper >= 0.13.0-330 + #if self.motion_queuing and hasattr(self.motion_queuing, 'check_step_generation_scan_windows'): + # self.motion_queuing.check_step_generation_scan_windows() + + # Debugging + #logging.info("MMU: ////////// CUTOVER fence t_cut=%.6f, old_trapq=%s, new_trapq=%s, from.last=%.6f, to.last=%.6f", + # t0, self._match_trapq(old_trapq), self._match_trapq(new_trapq), + # driving_toolhead.get_last_move_time(), + # following_toolhead.get_last_move_time()) + + # FORCE the RECEIVER (following_toolhead) planner to >= t0 and materialize it + dt = max(EPS, t0 - following_toolhead.get_last_move_time()) + if dt: following_toolhead.dwell(dt) + following_toolhead.flush_step_generation() + + if self.sync_mode == self.GEAR_SYNCED_TO_EXTRUDER: + self.printer.send_event("mmu:unsynced") + + # Now “unsynced” at t0 + + # Required for klipper >= 0.13.0-330 + if self.motion_queuing and hasattr(self.motion_queuing, 'check_step_generation_scan_windows'): + self.motion_queuing.check_step_generation_scan_windows() + + self.sync_mode = self.GEAR_ONLY if new_sync_mode == self.GEAR_ONLY else None + if new_sync_mode in [self.GEAR_ONLY, None]: + return prev_sync_mode + + # ---------------- Phase C: SYNC into new mode at t1 ----------------- + + self.mmu.log_stepper("sync(mode=%d %s)" % (new_sync_mode, ("gear+extruder" if new_sync_mode == self.EXTRUDER_SYNCED_TO_GEAR else "extruder" if new_sync_mode == self.EXTRUDER_ONLY_ON_GEAR else "extruder+gear"))) + + t1 = t0 + EPS # Later fence for the second cut over (t1 = t0 + EPS) + + # Figure out driver and follower based on sync mode + if new_sync_mode in [self.EXTRUDER_SYNCED_TO_GEAR, self.EXTRUDER_ONLY_ON_GEAR]: + driving_toolhead = self.mmu_toolhead # NEW owner (mmu/gear) + following_toolhead = self.printer_toolhead # OLD owner (printer/extruder) + following_steppers = [following_toolhead.get_extruder().extruder_stepper.stepper] + self._prev_trapq = following_steppers[0].get_trapq() # Save the *old* trapq **before** any rebind/unregister + driving_trapq = driving_toolhead.get_trapq() + s_alloc = ffi_lib.cartesian_stepper_alloc(b"y") + pos = [0., driving_toolhead.get_position()[1], 0.] + + # Cripple gear steppers and inject the extruder steppers into the gear rail + if new_sync_mode == self.EXTRUDER_ONLY_ON_GEAR: + for s in self.all_gear_rail_steppers: + self._unregister(self.mmu_toolhead, s) # s.set_trapq(None) + # Inject the extruder steppers to the end of the gear rail + gear_rail.steppers.extend(following_steppers) + + elif new_sync_mode == self.GEAR_SYNCED_TO_EXTRUDER: + driving_toolhead = self.printer_toolhead # NEW owner (printer/extruder) + following_toolhead = self.mmu_toolhead # OLD owner (mmu/gear) + following_steppers = list(following_toolhead.get_kinematics().rails[1].get_steppers()) + self._prev_trapq = following_toolhead.get_trapq() + driving_trapq = driving_toolhead.get_extruder().get_trapq() + s_alloc = ffi_lib.extruder_stepper_alloc() + pos = [driving_toolhead.get_position()[3], 0., 0.] + + else: + raise ValueError("Invalid sync_mode: %d" % new_sync_mode) + + # Hard close the old trapq up to the fence + _finalize_if_valid(self._prev_trapq, t1) + + # Switch ownership: unregister from the old TH, register on the new TH + self._prev_sk, self._prev_rd = [], [] + for s in following_steppers: + s_kinematics = ffi_main.gc(s_alloc, ffi_lib.free) + self._prev_sk.append(s.set_stepper_kinematics(s_kinematics)) + self._prev_rd.append(s.get_rotation_distance()[0]) + + # Remove from following toolhead, then attach to driving toolhead’s trapq + # s.set_trapq(driving_trapq) + self._unregister(following_toolhead, s) + self._register(driving_toolhead, s, trapq=driving_trapq) + + # Fix position + s.set_position(pos) + + # Debugging + #logging.info("MMU: ////////// CUTOVER fence t_cut=%.6f, old_trapq=%s, new_trapq=%s, from.last=%.6f, to.last=%.6f", + # t1, self._match_trapq(self._prev_trapq), self._match_trapq(driving_trapq), + # following_toolhead.get_last_move_time(), + # driving_toolhead.get_last_move_time()) + + # FORCE the NEW/RECEIVER (driving_toolhead) planner to >= t1 and materialize it + dt = max(EPS, t1 - driving_toolhead.get_last_move_time()) + driving_toolhead.dwell(dt) + driving_toolhead.flush_step_generation() + + # Now “synced” at t1 + + self.sync_mode = new_sync_mode + if self.sync_mode == self.GEAR_SYNCED_TO_EXTRUDER: + self.printer.send_event("mmu:synced") + + return prev_sync_mode + + def is_selector_homed(self): + return self.kin.get_status(self.reactor.monotonic())["selector_homed"] + + def get_status(self, eventtime): + res = super(MmuToolHead, self).get_status(eventtime) + res.update(dict(self.get_kinematics().get_status(eventtime))) + res.extend({ + 'filament_pos': self.mmu_toolhead.get_position()[1], + 'sync_mode': self.sync_mode + }) + return res + + cmd_DUMP_RAILS_help = "For debugging: dump current configuration of MMU Toolhead rails" + def cmd_DUMP_RAILS(self, gcmd): + show_endstops = gcmd.get_int('ENDSTOPS', 1, minval=0, maxval=1) + msg = self.dump_rails(show_endstops) + gcmd.respond_raw(msg) + + def _match_trapq(self, trapq): + p_th_trapq = self.printer_toolhead.get_trapq() + m_th_trapq = self.mmu_toolhead.get_trapq() + e_trapq = self.printer_toolhead.get_extruder().get_trapq() + ffi_main, ffi_lib = chelper.get_ffi() + if trapq is p_th_trapq: + return "Printer Toolhead" + elif trapq is m_th_trapq: + return "MMU Toolhead" + elif trapq is e_trapq: + return "Extruder" + elif trapq is None: + return "None" + elif trapq is ffi_main.NULL: + return "NULL" + return hex(id(trapq)) + + def sync_mode_to_string(self, mode): + if mode == self.EXTRUDER_SYNCED_TO_GEAR: + return "gear+extruder" + elif mode == self.EXTRUDER_ONLY_ON_GEAR: + return "extruder" + elif mode == self.GEAR_SYNCED_TO_EXTRUDER: + return "extruder+gear" + elif mode == self.GEAR_ONLY: + return "unsynced/gear" + return "unsynced" + + def dump_rails(self, show_endstops=True): + msg = "MMU TOOLHEAD: %s Last move time: %s\n" % (self.get_position(), self.mmu_toolhead.get_last_move_time()) + extruder_name = self.printer_toolhead.get_extruder().get_name() + for axis, rail in enumerate(self.get_kinematics().rails): + msg += "\n" if axis > 0 else "" + header = "RAIL: %s (Steppers: %d, Default endstops: %d, Extra endstops: %d) %s" % (rail.rail_name, len(rail.steppers), len(rail.endstops), len(rail.extra_endstops), '-' * 100) + msg += header[:100] + "\n" + gsd = None + if axis == 1: + rail_steppers = list(self.all_gear_rail_steppers) + for s in rail.get_steppers(): + if s not in rail_steppers: + rail_steppers.append(s) + else: + rail_steppers = rail.get_steppers() + for idx, s in enumerate(rail_steppers): + suffix = "" + if axis == 1: + if gsd is None: + gsd = s.get_step_dist() + if s in self.all_gear_rail_steppers and s not in self.selected_gear_steppers: + suffix = "*** INACTIVE ***" + msg += "Stepper %d: %s (trapq: %s) %s\n" % (idx, s.get_name(), self._match_trapq(s.get_trapq()), suffix) + msg += " - Commanded Pos: %.2f, " % s.get_commanded_position() + msg += "MCU Pos: %.2f, " % s.get_mcu_position() + rd = s.get_rotation_distance() + msg += "Rotation Dist: %.6f (in %d steps, step_dist=%.6f)\n" % (rd[0], rd[1], s.get_step_dist()) + + if show_endstops: + msg += "Endstops:\n" + for (mcu_endstop, name) in rail.endstops: + if mcu_endstop.__class__.__name__ == "MockEndstop": + msg += "- None (Mock - cannot home rail)\n" + else: + mcu_name = mcu_endstop.get_mcu().get_name() if hasattr(mcu_endstop, 'get_mcu') else mcu_endstop.__class__.__name__ + msg += "- %s%s, mcu: %s, pin: %s" % (name," (virtual)" if rail.is_endstop_virtual(name) else "", mcu_name, mcu_endstop._pin) + msg += " on: %s\n" % ["%d: %s" % (idx, s.get_name()) for idx, s in enumerate(mcu_endstop.get_steppers())] + msg += "Extra Endstops:\n" + for (mcu_endstop, name) in rail.extra_endstops: + mcu_name = mcu_endstop.get_mcu().get_name() if hasattr(mcu_endstop, 'get_mcu') else mcu_endstop.__class__.__name__ + msg += "- %s%s, mcu: %s, pin: %s" % (name, " (virtual)" if rail.is_endstop_virtual(name) else "", mcu_name, mcu_endstop._pin) + msg += " on: %s\n" % ["%d: %s" % (idx, s.get_name()) for idx, s in enumerate(mcu_endstop.get_steppers())] + + if axis == 1: # Gear rail + if self.is_gear_synced_to_extruder(): + msg += "SYNCHRONIZED: Gear rail synced to extruder '%s'\n" % extruder_name + if self.is_extruder_synced_to_gear(): + msg += "SYNCHRONIZED: Extruder '%s' synced to gear rail\n" % extruder_name + + e_stepper = self.printer_toolhead.get_extruder().extruder_stepper + msg += "\nPRINTER TOOLHEAD: %s Last move time: %s\n" % (self.printer_toolhead.get_position(), self.printer_toolhead.get_last_move_time()) + header = "Extruder Stepper: %s %s %s" % (extruder_name, "(MmuExtruderStepper)" if isinstance(e_stepper, MmuExtruderStepper) else "(Non Homing Default)", '-' * 100) + msg += header[:100] + "\n" + msg += " - Stepper trapq: %s\n" % self._match_trapq(e_stepper.stepper.get_trapq()) + msg += " - Commanded Pos: %.2f, " % e_stepper.stepper.get_commanded_position() + msg += "MCU Pos: %.2f, " % e_stepper.stepper.get_mcu_position() + rd = e_stepper.stepper.get_rotation_distance() + esd = e_stepper.stepper.get_step_dist() + msg += "Rotation Dist: %.6f (in %d steps, step_dist=%.6f)\n" % (rd[0], rd[1], esd) + if gsd is not None: + msg += "Step size ratio of gear:extruder = %.2f:1" % (gsd/esd) + return msg + + +# MMU Kinematics class +# (loosely based on corexy.py) +class MmuKinematics: + def __init__(self, toolhead, config): + self.printer = config.get_printer() + self.toolhead = toolhead + self.mmu_machine = self.printer.lookup_object('mmu_machine') + + # Setup "axis" rails + self.rails = [] + if self.mmu_machine.selector_type in ['LinearSelector', 'LinearServoSelector', 'LinearMultiGearSelector', 'RotarySelector']: + self.rails.append(MmuLookupMultiRail(config.getsection(SELECTOR_STEPPER_CONFIG), need_position_minmax=True, default_position_endstop=0.)) + self.rails[0].setup_itersolve('cartesian_stepper_alloc', b'x') + elif self.mmu_machine.selector_type in ['IndexedSelector']: + self.rails.append(MmuLookupMultiRail(config.getsection(SELECTOR_STEPPER_CONFIG), need_position_minmax=False, default_position_endstop=0.)) + self.rails[0].setup_itersolve('cartesian_stepper_alloc', b'x') + else: + self.rails.append(DummyRail()) + self.rails.append(MmuLookupMultiRail(config.getsection(GEAR_STEPPER_CONFIG), need_position_minmax=False, default_position_endstop=0.)) + self.rails[1].setup_itersolve('cartesian_stepper_alloc', b'y') + + for s in self.get_steppers(): + s.set_trapq(toolhead.get_trapq()) + if not self.toolhead.motion_queuing: + toolhead.register_step_generator(s.generate_steps) + + # Setup boundary checks + self.selector_max_velocity, self.selector_max_accel = toolhead.get_selector_limits() + self.gear_max_velocity, self.gear_max_accel = toolhead.get_gear_limits() + self.move_accel = None + self.limits = [(1.0, -1.0)] * len(self.rails) + + def get_steppers(self): + return [s for rail in self.rails for s in rail.get_steppers()] + + # Aka which stepper to choose to report movement + def calc_position(self, stepper_positions): + positions = [] + for i, r in enumerate(self.rails): + if isinstance(r, DummyRail): + positions.append(0.) + continue + s = next((s for s in r.steppers if s.get_trapq()), None) if i == 1 else None + name = s.get_name() if s else r.get_name() + positions.append(stepper_positions[name]) + return positions + + def set_position(self, newpos, homing_axes): + for i, rail in enumerate(self.rails): + if i == 1 and self.toolhead.is_gear_synced_to_extruder(): + continue + rail.set_position(newpos) + if i in homing_axes: + self.limits[i] = rail.get_range() + + def home(self, homing_state): + for axis in homing_state.get_axes(): + if not axis == 0: # Saftey: Only selector (axis[0]) can be homed TODO: make dependent on exact configuration + continue + rail = self.rails[axis] + position_min, position_max = rail.get_range() + hi = rail.get_homing_info() + homepos = [None, None, None, None] + homepos[axis] = hi.position_endstop + forcepos = list(homepos) + if hi.positive_dir: + forcepos[axis] -= 1.5 * (hi.position_endstop - position_min) + else: + forcepos[axis] += 1.5 * (position_max - hi.position_endstop) + homing_state.home_rails([rail], forcepos, homepos) # Perform homing + + def set_accel_limit(self, accel): + self.move_accel = accel + + def check_move(self, move): + if self.mmu_machine.selector_type in ['LinearSelector', 'LinearServoSelector', 'LinearMultiGearSelector', 'RotarySelector']: + limits = self.limits + xpos, _ = move.end_pos[:2] + if xpos != 0. and (xpos < limits[0][0] or xpos > limits[0][1]): + raise move.move_error() + + if move.axes_d[0]: # Selector + move.limit_speed(self.selector_max_velocity, min(self.selector_max_accel, self.move_accel or self.selector_max_accel)) + elif move.axes_d[1]: # Gear + move.limit_speed(self.gear_max_velocity, min(self.gear_max_accel, self.move_accel or self.gear_max_accel)) + + def get_status(self, eventtime): + axes = [a for a, (l, h) in zip("xy", self.limits) if l <= h] + return { + 'homed_axes': "".join(axes), + 'selector_homed': self.limits[0][0] <= self.limits[0][1], + } + + +# Extend Klipper homing module to leverage MMU "toolhead" +# (code pulled from homing.py) +class MmuHoming(Homing, object): + def __init__(self, printer, mmu_toolhead): + super(MmuHoming, self).__init__(printer) + self.toolhead = mmu_toolhead # Override default toolhead + + def home_rails(self, rails, forcepos, movepos): + # Notify of upcoming homing operation + self.printer.send_event("homing:home_rails_begin", self, rails) + # Alter kinematics class to think printer is at forcepos + homing_axes = [axis for axis in range(3) if forcepos[axis] is not None] + startpos = self._fill_coord(forcepos) + homepos = self._fill_coord(movepos) + self.toolhead.set_position(startpos, homing_axes=homing_axes) + # Perform first home + endstops = [es for rail in rails for es in rail.get_endstops()] + hi = rails[0].get_homing_info() + hmove = HomingMove(self.printer, endstops, self.toolhead) # Happy Hare: Override default toolhead + hmove.homing_move(homepos, hi.speed) + # Perform second home + if hi.retract_dist: + # Retract + startpos = self._fill_coord(forcepos) + homepos = self._fill_coord(movepos) + axes_d = [hp - sp for hp, sp in zip(homepos, startpos)] + move_d = math.sqrt(sum([d*d for d in axes_d[:3]])) + retract_r = min(1., hi.retract_dist / move_d) + retractpos = [hp - ad * retract_r + for hp, ad in zip(homepos, axes_d)] + self.toolhead.move(retractpos, hi.retract_speed) + # Home again + startpos = [rp - ad * retract_r + for rp, ad in zip(retractpos, axes_d)] + self.toolhead.set_position(startpos) + hmove = HomingMove(self.printer, endstops, self.toolhead) # Happy Hare: Override default toolhead + hmove.homing_move(homepos, hi.second_homing_speed) + if hmove.check_no_movement() is not None: + raise self.printer.command_error( + "Endstop %s still triggered after retract" + % (hmove.check_no_movement(),)) + # Signal home operation complete + self.toolhead.flush_step_generation() + self.trigger_mcu_pos = {sp.stepper_name: sp.trig_pos + for sp in hmove.stepper_positions} + self.adjust_pos = {} + self.printer.send_event("homing:home_rails_end", self, rails) + if any(self.adjust_pos.values()): + # Apply any homing offsets + kin = self.toolhead.get_kinematics() + homepos = self.toolhead.get_position() + kin_spos = {s.get_name(): (s.get_commanded_position() + + self.adjust_pos.get(s.get_name(), 0.)) + for s in kin.get_steppers()} + newpos = kin.calc_position(kin_spos) + for axis in homing_axes: + homepos[axis] = newpos[axis] + self.toolhead.set_position(homepos) + + +_StepperPrinterRail = stepper.PrinterRail if hasattr(stepper, 'PrinterRail') else stepper.GenericPrinterRail + + +# Extend PrinterRail to allow for multiple (switchable) endstops and to allow for no default endstop +# (defined in stepper.py) +class MmuPrinterRail(_StepperPrinterRail, object): + def __init__(self, config, **kwargs): + self.printer = config.get_printer() + self.config = config + self.rail_name = config.get_name() + self.query_endstops = self.printer.load_object(config, 'query_endstops') + self.extra_endstops = [] + self.virtual_endstops = [] + # Starting with klipper v0.13.0-79 there is a `config.get('endstop_pin')` with no default which throws an error + # So we must put a fake value in there to avoid the error, but we don't want to do that on older versions + if config.get('endstop_pin', None) is None and hasattr(super(MmuPrinterRail, self), 'lookup_endstop'): + config.fileconfig.set(config.section, 'endstop_pin', 'mock') + super(MmuPrinterRail, self).__init__(config, **kwargs) + + # Prior to klipper v0.13.0-79 this was done in the base class + if (len(self.get_steppers()) == 0): + self.add_stepper_from_config(config) + + def lookup_endstop(self, endstop_pin, name, **kwargs): + if endstop_pin == 'mock': + return self.MockEndstop() + elif hasattr(super(MmuPrinterRail, self), 'lookup_endstop'): + return super(MmuPrinterRail, self).lookup_endstop(endstop_pin, name, **kwargs) + + def add_stepper_from_config(self, config, **kwargs): + if not self.endstops and config.get('endstop_pin', None) is None: + # No endstop defined, so configure a mock endstop. The rail is, of course, only homable + # if it has a properly configured endstop at runtime + self.endstops = [(self.MockEndstop(), "mock")] # Hack: pretend we have a default endstop so super class will work + + logging.info("MMU: Setting up mmu stepper %s" % config.section) + + if hasattr(super(MmuPrinterRail, self), 'add_extra_stepper'): + super(MmuPrinterRail, self).add_extra_stepper(config, **kwargs) + else: + super(MmuPrinterRail, self).add_stepper_from_config(config, **kwargs) + + # Setup default endstop similarly to "extra" endstops with vanity sensor name + endstop_pin = config.get('endstop_pin', None) + if endstop_pin and endstop_pin != 'mock': + last_mcu_es=self.endstops[-1] + # Remove the default endstop name if alternative name is specified + endstop_name = config.get('endstop_name', None) + if endstop_name: + self.endstops.pop() + self.endstops.append((last_mcu_es[0], endstop_name)) + qee = self.query_endstops.endstops + if qee: + qee.pop() + self.query_endstops.register_endstop(self.endstops[0][0], endstop_name) + self.extra_endstops.append((last_mcu_es[0], endstop_name)) + self.extra_endstops.append((last_mcu_es[0], 'default')) + if 'virtual_endstop' in endstop_pin: + self.virtual_endstops.append(endstop_name) + if 'virtual_endstop' in endstop_pin: + self.virtual_endstops.append('default') + + # Handle any extra endstops + extra_endstop_pins = config.getlist('extra_endstop_pins', []) + extra_endstop_names = config.getlist('extra_endstop_names', []) + if extra_endstop_pins: + if len(extra_endstop_pins) != len(extra_endstop_names): + raise self.config.error("`extra_endstop_pins` and `extra_endstop_names` are different lengths") + for idx, pin in enumerate(extra_endstop_pins): + name = extra_endstop_names[idx] + self.add_extra_endstop(pin, name) + + # backwards compatibility (klipper v0.13.0-79) ... old: add_extra_stepper. new: add_stepper_from_config + def add_extra_stepper(self, config, **kwargs): + self.add_stepper_from_config(config, **kwargs) + + def add_extra_endstop(self, pin, name, register=True, bind_rail_steppers=True, mcu_endstop=None): + is_virtual = 'virtual_endstop' in pin + if is_virtual: + if name not in self.virtual_endstops: + self.virtual_endstops.append(name) + else: + raise self.config.error("Extra virtual endstop '%s' defined more than once" % name) + if mcu_endstop is None: + ppins = self.printer.lookup_object('pins') + mcu_endstop = ppins.setup_pin('endstop', pin) + self.extra_endstops.append((mcu_endstop, name)) + if bind_rail_steppers: + for s in self.steppers if not is_virtual else [self.steppers[-1]]: + try: + mcu_endstop.add_stepper(s) + except Exception as e: + logging.info("MMU: Not possible to add stepper %s to endstop %s because: %s" % (s.get_name(), name, str(e))) + if register: # and not self.is_endstop_virtual(name): + self.query_endstops.register_endstop(mcu_endstop, name) + return mcu_endstop + + def get_extra_endstop_names(self): + return [x[1] for x in self.extra_endstops] + + # Returns the endstop of given name as list to match get_endstops() + def get_extra_endstop(self, name): + for x in self.extra_endstops: + if x[1] == name: + return [x] + return None + + def is_endstop_virtual(self, name): + return name in self.virtual_endstops if name else False + + def set_direction(self, direction): + for stepper in self.steppers: + if not isinstance(stepper, (MmuExtruderStepper, ExtruderStepper)): + stepper.set_dir_inverted(direction) + + class MockEndstop: + def add_stepper(self, *args, **kwargs): + pass + + +# Wrapper for multiple stepper motor support +def MmuLookupMultiRail(config, need_position_minmax=True, default_position_endstop=None, units_in_radians=False): + rail = MmuPrinterRail(config, need_position_minmax=need_position_minmax, default_position_endstop=default_position_endstop, units_in_radians=units_in_radians) + for i in range(1, 23): # Don't allow "_0" or it is confusing with unprefixed initial stepper + section_name = "%s_%d" % (config.get_name(), i) + if not config.has_section(section_name): + break + rail.add_stepper_from_config(config.getsection(section_name)) + return rail + + +# Extend ExtruderStepper to allow for adding and managing endstops (useful only when part of gear rail, not operating as an Extruder) +class MmuExtruderStepper(ExtruderStepper, object): + def __init__(self, config, gear_rail): + super(MmuExtruderStepper, self).__init__(config) + + # Ensure corresponding TMC section is loaded so endstops can be added and to prevent error later when toolhead is created + for chip in TMC_CHIPS: + try: + _ = self.printer.load_object(config, '%s extruder' % chip) + break + except: + pass + + # This allows for setup of stallguard as an option for nozzle homing + endstop_pin = config.get('endstop_pin', None) + if endstop_pin: + mcu_endstop = gear_rail.add_extra_endstop(endstop_pin, 'mmu_ext_touch', bind_rail_steppers=False) + mcu_endstop.add_stepper(self.stepper) + + # Override to add QUIET option to control console logging + def cmd_SET_PRESSURE_ADVANCE(self, gcmd): + pressure_advance = gcmd.get_float('ADVANCE', self.pressure_advance, minval=0.) + smooth_time = gcmd.get_float('SMOOTH_TIME', self.pressure_advance_smooth_time, minval=0., maxval=.200) + self._set_pressure_advance(pressure_advance, smooth_time) + msg = "pressure_advance: %.6f\n" "pressure_advance_smooth_time: %.6f" % (pressure_advance, smooth_time) + self.printer.set_rollover_info(self.name, "%s: %s" % (self.name, msg)) + if not gcmd.get_int('QUIET', 0, minval=0, maxval=1): + gcmd.respond_info(msg, log=False) + +class DummyRail: + def __init__(self): + self.steppers = [] + self.endstops = [] + self.extra_endstops = [] + self.rail_name = "Dummy" + + def get_name(self): + return self.rail_name + + def get_steppers(self): + return self.steppers + + def get_endstops(self): + return self.endstops + + def set_position(self, newpos): + pass + + +def load_config(config): + return MmuMachine(config) diff --git a/extras/mmu_sensors.py b/extras/mmu_sensors.py new file mode 100644 index 000000000000..88e52ab1c104 --- /dev/null +++ b/extras/mmu_sensors.py @@ -0,0 +1,808 @@ +# Happy Hare MMU Software +# Easy setup of all sensors for MMU +# +# Pre-gate sensors: +# Simplifed filament switch sensor easy configuration of pre-gate sensors used to detect runout and insertion of filament +# and preload into gate and update gate_map when possible to do so based on MMU state, not printer state +# Essentially this uses the default `filament_switch_sensor` but then replaces the runout_helper +# Each has name `mmu_pre_gate_X` where X is gate number +# +# mmu_gear sensor(s): +# Wrapper around `filament_switch_sensor` setting up insert/runout callbacks with modified runout event handling +# Named `mmu_gear_X` where X is the gate number +# +# mmu_gate sensor(s): +# Wrapper around `filament_switch_sensor` setting up insert/runout callbacks with modified runout event handling +# Named `mmu_gate` +# +# extruder & toolhead sensor: +# Wrapper around `filament_switch_sensor` disabling all functionality - just for visability +# Named `extruder` & `toolhead` +# +# sync feedback sensor(s): +# Creates buttons handlers (with filament_switch_sensor for visibility and control) and publishes events based on state change +# Named `sync_feedback_compression` & `sync_feedback_tension` +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# RunoutHelper based on: +# Generic Filament Sensor Module Copyright (C) 2019 Eric Callahan +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +import logging, time + +import configparser, configfile + +INSERT_GCODE = "__MMU_SENSOR_INSERT" +REMOVE_GCODE = "__MMU_SENSOR_REMOVE" +RUNOUT_GCODE = "__MMU_SENSOR_RUNOUT" +CLOG_GCODE = "__MMU_SENSOR_CLOG" +TANGLE_GCODE = "__MMU_SENSOR_TANGLE" + + +# ------------------------------------------------------------------------------------------------- +# Enhanced "runout helper" that gives greater control of when filament sensor events are fired and +# direct access to button events in addition to creating a "remove" / "runout" distinction +class MmuRunoutHelper: + + def __init__(self, printer, name, event_delay, gcodes, insert_remove_in_print, button_handler, switch_pin): + """ + gcodes: dict of gcode macros to call for each event type. + Any key can be omitted or set to None/"" to disable that event. + """ + + self.printer, self.name = printer, name + + # Expecting a dict with keys like "insert", "remove", "runout", "clog", "tangle" + self.gcodes = gcodes or {} + + self.insert_remove_in_print = insert_remove_in_print + self.button_handler = button_handler + self.switch_pin = switch_pin + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object('gcode') + + self.min_event_systime = self.reactor.NEVER + self.event_delay = event_delay # Time between generated events + self.filament_present = False + self.sensor_enabled = True + self.runout_suspended = None + self.button_handler_suspended = False + + self.printer.register_event_handler("klippy:ready", self._handle_ready) + + # Replace previous runout_helper mux commands with ours + prev = self.gcode.mux_commands.get("QUERY_FILAMENT_SENSOR") + if prev: + _, prev_values = prev + prev_values[self.name] = self.cmd_QUERY_FILAMENT_SENSOR + + prev = self.gcode.mux_commands.get("SET_FILAMENT_SENSOR") + if prev: + _, prev_values = prev + prev_values[self.name] = self.cmd_SET_FILAMENT_SENSOR + + + def _handle_ready(self): + self.min_event_systime = self.reactor.monotonic() + 2. # Time to wait before first events are processed + + + def _insert_event_handler(self, eventtime): + insert_gcode = self.gcodes.get("insert") + self._exec_gcode("%s EVENTTIME=%s" % (insert_gcode, eventtime) if insert_gcode else None) + + + def _remove_event_handler(self, eventtime): + remove_gcode = self.gcodes.get("remove") + self._exec_gcode("%s EVENTTIME=%s" % (remove_gcode, eventtime) if remove_gcode else None) + + + def _runout_event_handler(self, eventtime, event_type): + # Pausing from inside an event requires that the pause portion of pause_resume execute immediately. + pause_resume = self.printer.lookup_object('pause_resume') + pause_resume.send_pause_command() + handler_gcode = self.gcodes.get(event_type) + self._exec_gcode("%s EVENTTIME=%s" % (handler_gcode, eventtime) if handler_gcode else None) + + + def _exec_gcode(self, command): + if command: + try: + self.gcode.run_script(command) + except Exception: + logging.exception("MMU: Error running mmu sensor handler: `%s`" % command) + self.min_event_systime = self.reactor.monotonic() + self.event_delay + + + # Latest klipper v0.12.0-462 added the passing of eventtime + # old: note_filament_present(self, is_filament_present): + # new: note_filament_present(self, eventtime, is_filament_present): + def note_filament_present(self, *args): + if len(args) == 1: + eventtime = self.reactor.monotonic() + is_filament_present = args[0] + else: + eventtime = args[0] + is_filament_present = args[1] + + prev_filament_present = self.filament_present + self.filament_present = bool(is_filament_present) + + # Button handlers are used for sync feedback state switches + if self.button_handler and not self.button_handler_suspended: + self.button_handler(eventtime, self.name, is_filament_present, self) + + if prev_filament_present == is_filament_present: + return + + # Don't handle too early or if disabled + if eventtime >= self.min_event_systime and self.sensor_enabled: + self._process_state_change(eventtime, is_filament_present) + + + def _process_state_change(self, eventtime, is_filament_present): + # Determine "printing" status + now = self.reactor.monotonic() + print_stats = self.printer.lookup_object("print_stats", None) + if print_stats is not None: + is_printing = print_stats.get_status(now)["state"] == "printing" + else: + is_printing = self.printer.lookup_object("idle_timeout").get_status(now)["state"] == "Printing" + + insert_gcode = self.gcodes.get("insert") + remove_gcode = self.gcodes.get("remove") + runout_gcode = self.gcodes.get("runout") + + if is_filament_present and insert_gcode: # Insert detected + if not is_printing or (is_printing and self.insert_remove_in_print): + #logging.info("MMU: filament sensor %s: insert event detected, Eventtime %.2f" % (self.name, eventtime)) + self.min_event_systime = self.reactor.NEVER # Prevent more callbacks until this one is complete + self.reactor.register_callback(lambda reh: self._insert_event_handler(eventtime)) + + else: # Remove or Runout detected + if is_printing and self.runout_suspended is False and runout_gcode: + #logging.info("MMU: filament sensor %s: runout event detected, Eventtime %.2f" % (self.name, eventtime)) + self.min_event_systime = self.reactor.NEVER # Prevent more callbacks until this one is complete + self.reactor.register_callback(lambda reh: self._runout_event_handler(eventtime, "runout")) + elif remove_gcode and (not is_printing or self.insert_remove_in_print): + # Just a "remove" event + #logging.info("MMU: filament sensor %s: remove event detected, Eventtime %.2f" % (self.name, eventtime)) + self.min_event_systime = self.reactor.NEVER # Prevent more callbacks until this one is complete + self.reactor.register_callback(lambda reh: self._remove_event_handler(eventtime)) + + + def note_clog_tangle(self, event_type): + #logging.info("MMU: filament sensor %s: %s event detected, Eventtime %.2f" % (self.name, event_type, eventtime)) + now = self.reactor.monotonic() + self.min_event_systime = self.reactor.NEVER # Prevent more callbacks until this one is complete + self.reactor.register_callback(lambda reh: self._runout_event_handler(now, event_type)) + + + def enable_runout(self, restore): + self.runout_suspended = not restore + + + def enable_button_feedback(self, restore): + self.button_handler_suspended = not restore + + + def get_status(self, eventtime=None): + return { + "filament_detected": bool(self.filament_present), + "enabled": bool(self.sensor_enabled), + "runout_suspended": bool(self.runout_suspended), + } + + + cmd_QUERY_FILAMENT_SENSOR_help = "Query the status of the Filament Sensor" + def cmd_QUERY_FILAMENT_SENSOR(self, gcmd): + if self.filament_present: + msg = "MMU Sensor %s: filament detected" % (self.name) + else: + msg = "MMU Sensor %s: filament not detected" % (self.name) + gcmd.respond_info(msg) + + + cmd_SET_FILAMENT_SENSOR_help = "Sets the filament sensor on/off" + def cmd_SET_FILAMENT_SENSOR(self, gcmd): + self.sensor_enabled = bool(gcmd.get_int("ENABLE", 1)) + + + +# ------------------------------------------------------------------------------------------------- +# Analog Filament Tension Sensor used for proportional sync-feedback +# Maps sensor range to [-1,1] +class MmuProportionalSensor: + + def __init__(self, config, name): + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.name = name + self._last_extreme = None + + # Config + self._pin = config.get('sync_feedback_analog_pin') + max_tension = config.getfloat('sync_feedback_analog_max_tension', 1) + max_compression = config.getfloat('sync_feedback_analog_max_compression', 0) + + # Determine the actual raw min/max sensor values + raw_min = min(max_tension, max_compression) + raw_max = max(max_tension, max_compression) + mid_point = (max_tension + max_compression) / 2.0 + + self._neutral_point = config.getfloat('sync_feedback_analog_neutral_point', mid_point, minval=raw_min, maxval=raw_max) + + self._gamma = config.getfloat('sync_feedback_analog_gamma', 1) # Not exposed + self._sample_time = config.getfloat('sync_feedback_analog_sample_time', 0.005) # Not exposed + self._sample_count = config.getint('sync_feedback_analog_sample_count', 5) # Not exposed + self._report_time = config.getfloat('sync_feedback_analog_report_time', 0.100) # Not exposed + + self._reversed = (max_compression < max_tension) + eps = 1e-12 + if not self._reversed: + # Tension low, Compression high value + self._d_neg = max(self._neutral_point - max_tension, eps) + self._d_pos = max(max_compression - self._neutral_point, eps) + else: + # Compression low, Tension high value + self._d_pos = max(self._neutral_point - max_compression, eps) + self._d_neg = max(max_tension - self._neutral_point, eps) + + # State + self.value_raw = 0.0 # Raw ADC value + self.value = 0.0 # In [-1.0, 1.0] + + # Setup ADC + ppins = self.printer.lookup_object('pins') + self.adc = ppins.setup_pin('adc', self._pin) + + if hasattr(self.adc, "setup_minmax"): + # Kalico and older klipper + self.adc.setup_minmax(self._sample_time, self._sample_count) + self.adc.setup_adc_callback(self._report_time, self._adc_callback) + else: + try: + # New klipper (>= v0.13.0-557) + self.adc.setup_adc_sample(self._report_time, self._sample_time, self._sample_count) + self.adc.setup_adc_callback(self._adc_callback) + except TypeError: + # A few versions of klipper had these signatures + self.adc.setup_adc_sample(self._sample_time, self._sample_count) + self.adc.setup_adc_callback(self._report_time, self._adc_callback) + + # Attach runout_helper (no gcode actions; just enable/disable plumbing to remove UI nag) + clog_gcode = ("%s SENSOR=%s" % (CLOG_GCODE, name)) + tangle_gcode = ("%s SENSOR=%s" % (TANGLE_GCODE, name)) + self.runout_helper = MmuRunoutHelper( + self.printer, + self.name, # Name exposed to QUERY_/SET_FILAMENT_SENSOR + 0, # Event_delay (not used here) + { + "clog": clog_gcode, + "tangle": tangle_gcode, + }, + insert_remove_in_print=False, + button_handler=None, # No button handler for analog + switch_pin=self._pin + ) + + # Expose status + self.printer.add_object(self.name, self) + + def _map_reading(self, v_raw): + n = self._neutral_point + + v = float(v_raw) + # Map around neutral_point into [-1, 1] + if not self._reversed: + if v >= n: + y = (v - n) / self._d_pos + else: + y = -(n - v) / self._d_neg + else: + if v <= n: + y = (n - v) / self._d_pos + else: + y = -(v - n) / self._d_neg + + # Optional shaping (gamma=1 => linear) + if self._gamma != 1.0: + y = (abs(y) ** self._gamma) * (1.0 if y >= 0 else -1.0) + + # Clamp + if y < -1.0: y = -1.0 + if y > 1.0: y = 1.0 + return y + + def _adc_callback(self, *args): + # Old klipper: _adc_callback(read_time, read_value) + # New klipper: _adc_callback(samples) where samples is a list of (read_time, read_value) + if len(args) == 1: + samples = args[0] + read_time, read_value = samples[-1] + elif len(args) == 2: + read_time, read_value = args + else: + raise TypeError("_adc_callback expected (read_time, read_value) or (samples), got %d args" % len(args)) + + self.value_raw = float(read_value) + self.value = self._map_reading(read_value) # Mapped & scaled value + + # Publish sync-feedback event immediately if extreme to match switch sensors + # TODO really extreme should be determined by is_extreme() in mmu_sync_feedback manager (with hysteresis), but object hasn't been created yet + # TODO so for now, use absolute extremes + if abs(self.value) >= 1.0: + extreme = 1 if self.value > 0 else -1 + if extreme != self._last_extreme: # Avoid repeated events + self._last_extreme = extreme + self.printer.send_event("mmu:sync_feedback", read_time, self.value) + + def get_status(self, eventtime): + return { + "enabled": bool(self.runout_helper.sensor_enabled), + "value": self.value, # in [-1.0, 1.0] (mapped) + "value_raw": self.value_raw, # raw + } + + + +# ------------------------------------------------------------------------------------------------- +# EXPERIMENTAL/HACK +# Support ViViD analog buffer "endstops" +# This class implments both the filament switch sensor and endstop. However: +# * it will not display in UI because no filament_switch_sensor exists in config +# * does not involve the mcu in the homing process so it can't be accurate +# * suffers from inherent averaging lag for analog inputs +class MmuAdcSwitchSensor: + + def __init__(self, config, name_prefix, gate, switch_pin, event_delay, + a_range, + insert=False, remove=False, runout=False, clog=False, tangle=False, + insert_remove_in_print=False, button_handler=None, + a_pullup=4700.): + + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self._pin = switch_pin + self._steppers = [] + self._trigger_completion = None + self._last_trigger_time = None + + buttons = self.printer.load_object(config, 'buttons') + a_min, a_max = a_range + buttons.register_adc_button(switch_pin, a_min, a_max, a_pullup, self._button_handler) + self.name = name = "%s_%d" % (name_prefix, gate) + insert_gcode = ("%s SENSOR=%s%s" % (INSERT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if insert else None + remove_gcode = ("%s SENSOR=%s%s" % (REMOVE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if remove else None + runout_gcode = ("%s SENSOR=%s%s" % (RUNOUT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if runout else None + clog_gcode = ("%s SENSOR=%s%s" % (CLOG_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if clog else None + tangle_gcode = ("%s SENSOR=%s%s" % (TANGLE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if tangle else None + self.runout_helper = MmuRunoutHelper( + self.printer, + name, + event_delay, + { + "insert": insert_gcode, + "remove": remove_gcode, + "runout": runout_gcode, + "clog": clog_gcode, + "tangle": tangle_gcode, + }, + insert_remove_in_print, + button_handler, + switch_pin, + ) + self.get_status = self.runout_helper.get_status + + + def _button_handler(self, eventtime, state): + self.runout_helper.note_filament_present(eventtime, state) + if self._trigger_completion is not None: + self._last_trigger_time = eventtime + self._trigger_completion.complete(True) + + + # Required to implement an endstop ------- + + def query_endstop(self, print_time): + return self.runout_helper.filament_present + + + def setup_pin(self, pin_type, pin_name): + return self + + + def add_stepper(self, stepper): + self._steppers.append(stepper) + + + def get_steppers(self): + return list(self._steppers) + + + def home_start(self, print_time, sample_time, sample_count, rest_time, triggered): + self._trigger_completion = self.reactor.completion() + self._last_trigger_time = None + self._homing = True + self._triggered = triggered + + if self.runout_helper.filament_present == self._triggered: + self._last_trigger_time = print_time + self._trigger_completion.complete(True) + + return self._trigger_completion + + + def home_wait(self, home_end_time): + self._homing = False + self._trigger_completion = None + + if self._last_trigger_time is None: + raise self.printer.command_error("No trigger on %s after full movement" % self.name) + + return self._last_trigger_time + + + +# ------------------------------------------------------------------------------------------------- +# EXPERIMENTAL +# Standalone Hall Filament Sensor Endstop using Multi-Use Pins +# Can coexists with standard Klipper hall_filament_width_sensor by sharing the ADC pins +class MmuHallEndstop: + def __init__(self, config, name, pin1, pin2, cal_dia1, raw_dia1, cal_dia2, raw_dia2, + hall_runout_dia=1., + insert=False, remove=False, runout=False, clog=False, tangle=False): + + self.printer = config.get_printer() + self.name = name + + # Configurable sampling for fast endstop response + # Defaults: 1ms sample, 8 samples = 8ms. Report every 10ms. + self.sample_time = config.getfloat('hall_sample_time', 0.001, above=0.0) + self.sample_count = config.getint('hall_sample_count', 8, minval=1) + self.report_time = config.getfloat('hall_report_time', 0.010, above=0.0) + + # Sensor configuration for diameter calculation + self.pin1_name = pin1 + self.pin2_name = pin2 + self.dia1 = cal_dia1 + self.rawdia1 = raw_dia1 + self.dia2 = cal_dia2 + self.rawdia2 = raw_dia2 + self.hall_min_diameter = hall_runout_dia + + # State + self.lastFilamentWidthReading = 0 + self.lastFilamentWidthReading2 = 0 + self.diameter = 0 + self.is_active = True # Always active for endstop purposes? or should be toggleable? + + # Endstop state variables + self._steppers = [] + self._trigger_completion = None + self._last_trigger_time = None + self._homing = False + self._triggered = False + + # Setup Hardware (Multi-Use) + ppins = self.printer.lookup_object('pins') + + _kalico = hasattr(self.adc, "setup_minmax") # Kalico and older klipper + # ADC 1 + if self.pin1_name: + ppins.allow_multi_use_pin(self.pin1_name) + self.mcu_adc = ppins.setup_pin('adc', self.pin1_name) + if _kalico: + self.mcu_adc.setup_minmax(self.sample_time, self.sample_count) + else: + self.mcu_adc.setup_adc_sample(self.sample_time, self.sample_count) + self.mcu_adc.setup_adc_callback(self.report_time, self.adc_callback) + + # ADC 2 (Optional) + self.mcu_adc2 = None + if self.pin2_name: + ppins.allow_multi_use_pin(self.pin2_name) + self.mcu_adc2 = ppins.setup_pin('adc', self.pin2_name) + if _kalico: + self.mcu_adc2.setup_minmax(self.sample_time, self.sample_count) + else: + self.mcu_adc2.setup_adc_sample(self.sample_time, self.sample_count) + self.mcu_adc2.setup_adc_callback(self.report_time, self.adc2_callback) + + # Setup runout helper/virtual sensor for MMU integration + event_delay = 0.5 + insert_gcode = ("%s SENSOR=%s" % (INSERT_GCODE, name)) if insert else None + remove_gcode = ("%s SENSOR=%s" % (REMOVE_GCODE, name)) if remove else None + runout_gcode = ("%s SENSOR=%s" % (RUNOUT_GCODE, name)) if runout else None + + # We pass "None" for switch_pin because we manage the pin state via ADC logic + self.runout_helper = MmuRunoutHelper( + self.printer, + name, + event_delay, + { + "insert": insert_gcode, + "remove": remove_gcode, + "runout": runout_gcode + }, + insert_remove_in_print=False, + button_handler=None, + switch_pin=None + ) + + self.printer.add_object("mmu_hall_endstop %s" % name, self) + + def _calc_diameter(self): + # Duplicate of Klipper hall_filament_width_sensor logic + try: + val_sum = self.lastFilamentWidthReading + self.lastFilamentWidthReading2 + slope = (self.dia2 - self.dia1) / (self.rawdia2 - self.rawdia1) + diameter_new = round(slope * (val_sum - self.rawdia1) + self.dia1, 2) + # Use same smoothing factor as Klipper? Or faster for endstop? + # Klipper: self.diameter = (5.0 * self.diameter + diameter_new) / 6 + # For endstop we probably want instant reaction or less smoothing + self.diameter = (2.0 * self.diameter + diameter_new) / 3 # Slightly faster smoothing + except ZeroDivisionError: + self.diameter = 1.75 # Default fallback + + def adc_callback(self, read_time, read_value): + self.lastFilamentWidthReading = round(read_value * 10000) + self._calc_diameter() + self._check_trigger(read_time) + + def adc2_callback(self, read_time, read_value): + self.lastFilamentWidthReading2 = round(read_value * 10000) + self._calc_diameter() + self._check_trigger(read_time) + + def _check_trigger(self, eventtime): + is_present = self.diameter > self.hall_min_diameter + self.runout_helper.note_filament_present(eventtime, is_present) + + if self._homing: + if is_present == self._triggered: + if self._trigger_completion is not None: + self._last_trigger_time = eventtime + self._trigger_completion.complete(True) + self._trigger_completion = None + + def get_status(self, eventtime): + status = self.runout_helper.get_status(eventtime) + status.update({ + "Diameter": self.diameter, + "Raw": (self.lastFilamentWidthReading + self.lastFilamentWidthReading2) + }) + return status + + # Required to implement a HH MMU endstop ------- + + def query_endstop(self, print_time): + return self.runout_helper.filament_present + + def setup_pin(self, pin_type, pin_name): + return self + + def add_stepper(self, stepper): + self._steppers.append(stepper) + + def get_steppers(self): + return list(self._steppers) + + def home_start(self, print_time, sample_time, sample_count, rest_time, triggered): + self._trigger_completion = self.reactor.completion() + self._last_trigger_time = None + self._homing = True + self._triggered = triggered + + if self.runout_helper.filament_present == self._triggered: + self._last_trigger_time = print_time + self._trigger_completion.complete(True) + + return self._trigger_completion + + def home_wait(self, home_end_time): + self._homing = False + self._trigger_completion = None + + if self._last_trigger_time is None: + raise self.printer.command_error("No trigger on %s after full movement" % self.name) + + return self._last_trigger_time + + + +class MmuSensors: + + def __init__(self, config): + from .mmu import Mmu # For sensor names + + self.printer = config.get_printer() + self.sensors = {} + mmu_machine = self.printer.lookup_object("mmu_machine", None) + num_units = mmu_machine.num_units if mmu_machine else 1 + event_delay = config.get('event_delay', 0.5) + + # Setup "mmu_pre_gate" sensors... + for gate in range(23): + switch_pin = config.get('pre_gate_switch_pin_%d' % gate, None) + if switch_pin: + self._create_mmu_sensor(config, Mmu.SENSOR_PRE_GATE_PREFIX, gate, switch_pin, event_delay, insert=True, remove=True, runout=True, insert_remove_in_print=True) + + # Setup single "mmu_gate" sensor(s)... + switch_pins = list(config.getlist('gate_switch_pin', [])) + if switch_pins: + if len(switch_pins) not in [1, num_units]: + raise config.error("Invalid number of pins specified with gate_switch_pin. Expected 1 or %d but counted %d" % (num_units, len(switch_pins))) + self._create_mmu_sensor(config, Mmu.SENSOR_GATE, None, switch_pins, event_delay, runout=True) + + # Setup "mmu_gear" sensors... + for gate in range(23): + switch_pin = config.get('post_gear_switch_pin_%d' % gate, None) + if switch_pin: + a_range = config.getfloatlist('post_gear_analog_range_%d' % gate, None, count=2) + if a_range is not None: + a_pullup = config.getfloat('post_gear_analog_pullup_resister_%d' % gate, 4700.) + s = MmuAdcSwitchSensor(config, Mmu.SENSOR_GEAR_PREFIX, gate, switch_pin, event_delay, a_range, runout=True, a_pullup=a_pullup) + self.sensors["%s_%d" % (Mmu.SENSOR_GEAR_PREFIX, gate)] = s + else: + self._create_mmu_sensor(config, Mmu.SENSOR_GEAR_PREFIX, gate, switch_pin, event_delay, runout=True) + + # Setup single extruder (entrance) sensor... + switch_pin = config.get('extruder_switch_pin', None) + if switch_pin: + self._create_mmu_sensor(config, Mmu.SENSOR_EXTRUDER_ENTRY, None, switch_pin, event_delay, insert=True, runout=True) + + # Setup single toolhead sensor... + switch_pin = config.get('toolhead_switch_pin', None) + if switch_pin: + self._create_mmu_sensor(config, Mmu.SENSOR_TOOLHEAD, None, switch_pin, event_delay) + + # For Qidi printers or any other that use a hall_filament_width_sensor as an endstop + hall_sensor_endstop = config.get('hall_sensor_endstop', None) + if hall_sensor_endstop is not None: + if hall_sensor_endstop == 'gate': + target_name = Mmu.SENSOR_GATE + elif hall_sensor_endstop == 'extruder': + target_name = Mmu.SENSOR_EXTRUDER_ENTRY + elif hall_sensor_endstop == 'toolhead': + target_name = Mmu.SENSOR_TOOLHEAD + else: + target_name = hall_sensor_endstop + + self.hall_pin1 = config.get('hall_adc1') + self.hall_pin2 = config.get('hall_adc2') + self.hall_dia1 = config.getfloat('hall_cal_dia1', 1.5) + self.hall_dia2 = config.getfloat('hall_cal_dia2', 2.0) + self.hall_rawdia1 = config.getint('hall_raw_dia1', 9500) + self.hall_rawdia2 = config.getint('hall_raw_dia2', 10500) + self.hall_runout_dia = config.getfloat('hall_min_diameter', 1.0) + # self.hall_runout_dia_max = config.getfloat('hall_max_diameter', 2.0) - Unused for trigger + + s = MmuHallEndstop(config, target_name, self.hall_pin1, self.hall_pin2, + self.hall_dia1, self.hall_rawdia1, self.hall_dia2, self.hall_rawdia2, + hall_runout_dia=self.hall_runout_dia, + insert=True, runout=True) + self.sensors[target_name] = s + + # Setup motor syncing feedback sensors... + switch_pins = list(config.getlist('sync_feedback_tension_pin', [])) + if switch_pins: + if len(switch_pins) not in [1, num_units]: + raise config.error("Invalid number of pins specified with sync_feedback_tension_pin. Expected 1 or %d but counted %d" % (num_units, len(switch_pins))) + self._create_mmu_sensor(config, Mmu.SENSOR_TENSION, None, switch_pins, 0, clog=True, tangle=True, button_handler=self._sync_tension_callback) + switch_pins = list(config.getlist('sync_feedback_compression_pin', [])) + if switch_pins: + if len(switch_pins) not in [1, num_units]: + raise config.error("Invalid number of pins specified with sync_feedback_compression_pin. Expected 1 or %d but counted %d" % (num_units, len(switch_pins))) + self._create_mmu_sensor(config, Mmu.SENSOR_COMPRESSION, None, switch_pins, 0, clog=True, tangle=True, button_handler=self._sync_compression_callback) + + # Setup analog (proportional) sync feedback + # Uses single analog input; value scaled in [-1, 1] + analog_pin = config.get('sync_feedback_analog_pin', None) + if analog_pin: + self.sensors[Mmu.SENSOR_PROPORTIONAL] = MmuProportionalSensor(config, name=Mmu.SENSOR_PROPORTIONAL) + + + def _create_mmu_sensor( + self, config, name_prefix, gate, switch_pins, event_delay, + insert=False, remove=False, runout=False, clog=False, tangle=False, + insert_remove_in_print=False, button_handler=None, + ): + switch_pins = [switch_pins] if not isinstance(switch_pins, list) else switch_pins + for unit, switch_pin in enumerate(switch_pins): + if not self._is_empty_pin(switch_pin): + # name must match mmu_sensor_manager + if gate is not None: + name = "%s_%d" % (name_prefix, gate) + elif len(switch_pins) > 1: + name = "unit_%d_%s" % (unit, name_prefix) + else: + name = name_prefix + sensor = name if gate is not None else "%s_sensor" % name + section = "filament_switch_sensor %s" % sensor + config.fileconfig.add_section(section) + config.fileconfig.set(section, "switch_pin", switch_pin) + config.fileconfig.set(section, "pause_on_runout", "False") + fs = self.printer.load_object(config, section) + + # Replace with custom runout_helper because of state specific behavior + insert_gcode = ("%s SENSOR=%s%s" % (INSERT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if insert else None + remove_gcode = ("%s SENSOR=%s%s" % (REMOVE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if remove else None + runout_gcode = ("%s SENSOR=%s%s" % (RUNOUT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if runout else None + clog_gcode = ("%s SENSOR=%s%s" % (CLOG_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if clog else None + tangle_gcode = ("%s SENSOR=%s%s" % (TANGLE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if tangle else None + ro_helper = MmuRunoutHelper( + self.printer, + sensor, + event_delay, + { + "insert": insert_gcode, + "remove": remove_gcode, + "runout": runout_gcode, + "clog": clog_gcode, + "tangle": tangle_gcode, + }, + insert_remove_in_print, + button_handler, + switch_pin + ) + fs.runout_helper = ro_helper + fs.get_status = ro_helper.get_status + self.sensors[name] = fs + + + def _is_empty_pin(self, switch_pin): + if switch_pin == '': return True + ppins = self.printer.lookup_object('pins') + pin_params = ppins.parse_pin(switch_pin, can_invert=True, can_pullup=True) + pin_resolver = ppins.get_pin_resolver(pin_params['chip_name']) + real_pin = pin_resolver.aliases.get(pin_params['pin'], '_real_') + return real_pin == '' + + + def _sync_tension_callback(self, eventtime, t_sensor_name, tension_state, runout_helper): + """ + Button event handler for sync-feedback tension switch + """ + from .mmu import Mmu # For sensor names + c_sensor_name = t_sensor_name.replace(Mmu.SENSOR_TENSION, Mmu.SENSOR_COMPRESSION) + compression_sensor = self.printer.lookup_object("filament_switch_sensor %s" % c_sensor_name, None) + compression_enabled = compression_sensor.runout_helper.sensor_enabled if compression_sensor else False + compression_state = compression_sensor.runout_helper.filament_present if compression_enabled else False + + if compression_enabled: + event_value = 0 if tension_state == compression_state else (-1 if tension_state else 1) # {-1,0,1} + else: + event_value = -tension_state # {0,-1} + + # Send event now so it is processed as early as possible + self.printer.send_event("mmu:sync_feedback", eventtime, event_value) + + + def _sync_compression_callback(self, eventtime, c_sensor_name, compression_state, runout_helper): + """ + Button event handler for sync-feedback compression switch + """ + from .mmu import Mmu + t_sensor_name = c_sensor_name.replace(Mmu.SENSOR_COMPRESSION, Mmu.SENSOR_TENSION) + tension_sensor = self.printer.lookup_object("filament_switch_sensor %s" % t_sensor_name, None) + tension_enabled = tension_sensor.runout_helper.sensor_enabled if tension_sensor else False + tension_state = tension_sensor.runout_helper.filament_present if tension_enabled else False + + if tension_enabled: + event_value = 0 if compression_state == tension_state else (1 if compression_state else -1) # {-1,0,1} + else: + event_value = compression_state # {1,0} + + # Send event now so it is processed as early as possible + self.printer.send_event("mmu:sync_feedback", eventtime, event_value) + + +def load_config(config): + return MmuSensors(config) diff --git a/extras/mmu_servo.py b/extras/mmu_servo.py new file mode 100644 index 000000000000..9783ec1f3433 --- /dev/null +++ b/extras/mmu_servo.py @@ -0,0 +1,115 @@ +# Happy Hare MMU Software +# Custom servo support that carefully synchronizes PWM changes to avoid "kickback" caused +# by a truncated final pulse with digital servos. +# All existing servo functionality is available with the addition of a 'DURATION' +# parameter for setting PWM pulse train with auto off +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Based on original servo.py Copyright (C) 2017-2020 Kevin O'Connor +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, time + +SERVO_SIGNAL_PERIOD = 0.020 +PIN_MIN_TIME = 0.100 + +class MmuServo: + def __init__(self, config): + self.printer = config.get_printer() + self.min_width = config.getfloat('minimum_pulse_width', .001, above=0., below=SERVO_SIGNAL_PERIOD) + self.max_width = config.getfloat('maximum_pulse_width', .002, above=self.min_width, below=SERVO_SIGNAL_PERIOD) + self.max_angle = config.getfloat('maximum_servo_angle', 180.) + self.angle_to_width = (self.max_width - self.min_width) / self.max_angle + self.width_to_value = 1. / SERVO_SIGNAL_PERIOD + self.last_value = self.last_value_time = 0. + initial_pwm = 0. + iangle = config.getfloat('initial_angle', None, minval=0., maxval=360.) + if iangle is not None: + initial_pwm = self._get_pwm_from_angle(iangle) + else: + iwidth = config.getfloat('initial_pulse_width', 0., minval=0., maxval=self.max_width) + initial_pwm = self._get_pwm_from_pulse_width(iwidth) + self.last_value = initial_pwm + + # 50% of the "off" period is the best place to change PWM signal + self.pwm_period_safe_offset = SERVO_SIGNAL_PERIOD - (SERVO_SIGNAL_PERIOD - self.max_width) / 2 + + # Setup mcu_servo pin + ppins = self.printer.lookup_object('pins') + self.mcu_servo = ppins.setup_pin('pwm', config.get('pin')) + self.mcu_servo.setup_max_duration(0.) + self.mcu_servo.setup_cycle_time(SERVO_SIGNAL_PERIOD) + self.mcu_servo.setup_start_value(initial_pwm, 0.) + + # Register command + servo_name = config.get_name().split()[1] + gcode = self.printer.lookup_object('gcode') + gcode.register_mux_command("SET_SERVO", "SERVO", servo_name, self.cmd_SET_SERVO, desc=self.cmd_SET_SERVO_help) + + def get_status(self, eventtime): + return {'value': self.last_value} + + def _set_pwm(self, print_time, value, duration): + if value == self.last_value: + return + + print_time = max(print_time, self.last_value_time + PIN_MIN_TIME) + pwm_start_time = self._get_synced_print_time(print_time) + if duration is None: + self.mcu_servo.set_pwm(pwm_start_time, value) + self.last_value = value + self.last_value_time = pwm_start_time + else: + # Translate duration to ticks to avoid any secondary mcu clock skew + mcu = self.mcu_servo.get_mcu() + cmd_clock = mcu.print_time_to_clock(pwm_start_time) + burst = int(duration / SERVO_SIGNAL_PERIOD) * SERVO_SIGNAL_PERIOD + cmd_clock += mcu.seconds_to_clock(max(SERVO_SIGNAL_PERIOD, burst) + self.pwm_period_safe_offset) + pwm_end_time = mcu.clock_to_print_time(cmd_clock) + # Schedule PWM burst + self.mcu_servo.set_pwm(pwm_start_time, value) + self.mcu_servo.set_pwm(pwm_end_time, 0.) + # Update time tracking + self.last_value = 0. + self.last_value_time = pwm_end_time + + # Return a print_time that is a safe place to change PWM signal + def _get_synced_print_time(self, print_time): + if self.last_value != 0.: # If servo already off time syncing is not necessary + skew = (print_time - self.last_value_time) % SERVO_SIGNAL_PERIOD + print_time -= skew # Align on previous SERVO_SIGNAL_PERIOD boundary + print_time += self.pwm_period_safe_offset + return print_time + + def _get_pwm_from_angle(self, angle): + angle = max(0., min(self.max_angle, angle)) + width = self.min_width + angle * self.angle_to_width + return width * self.width_to_value + + def _get_pwm_from_pulse_width(self, width): + width = max(self.min_width, min(self.max_width, width)) if width else width + return width * self.width_to_value + + cmd_SET_SERVO_help = "Set servo angle" + def cmd_SET_SERVO(self, gcmd): + duration = gcmd.get_float('DURATION', None, minval=PIN_MIN_TIME) + width = gcmd.get_float('WIDTH', None) + angle = gcmd.get_float('ANGLE', None) + self.set_position(width, angle, duration) + + def set_position(self, width=None, angle=None, duration=None): + duration = max(duration, SERVO_SIGNAL_PERIOD) if duration else None + if width is not None or angle is not None: + value = self._get_pwm_from_pulse_width(width) if width is not None else self._get_pwm_from_angle(angle) + pt = self.printer.lookup_object('toolhead').get_last_move_time() + self._set_pwm(pt, value, duration) + +def load_config_prefix(config): + return MmuServo(config) diff --git a/install.sh b/install.sh new file mode 100755 index 000000000000..e7e0213fcce6 --- /dev/null +++ b/install.sh @@ -0,0 +1,2919 @@ +#!/bin/bash +# Happy Hare MMU Software +# +# Installer / Updater script +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Creality K1 Support +# 2024 hamyy +# 2024 Unsweeticetea +# 2024 Dmitry Kychanov +# +VERSION=3.42 # Important: Keep synced with mmy.py + +F_VERSION=$(echo "$VERSION" | sed 's/\([0-9]\+\)\.\([0-9]\)\([0-9]\)/\1.\2.\3/') +SCRIPT="$(readlink -f "$0")" +SCRIPTFILE="$(basename "$SCRIPT")" +SCRIPTPATH="$(dirname "$SCRIPT")" +SCRIPTNAME="$0" +ARGS=( "$@" ) + +# Provide klipper installation path and settings for different systems + +OS_CREALITY_K1="creality-k1" +OS_FLYOS_FAST="flyos-fast" +OS_TYPE="" +if [ $(uname -m) = "mips" ] && [ -d "/usr/data/creality" ]; then + OS_TYPE="${OS_CREALITY_K1}" + echo "Detected Creality K1 series printer" +elif [ $(sed -n 's/^NAME="\(.*\)"/\1/p' /etc/os-release 2>/dev/null) = "FlyOS-Fast" ]; then + OS_TYPE="${OS_FLYOS_FAST}" + echo "Detected FlyOS-Fast" +fi + +KLIPPER_HOME="${HOME}/klipper" +MOONRAKER_HOME="${HOME}/moonraker" +KLIPPER_CONFIG_HOME="${HOME}/printer_data/config" +OCTOPRINT_KLIPPER_CONFIG_HOME="${HOME}" +KLIPPER_LOGS_HOME="${HOME}/printer_data/logs" +OLD_KLIPPER_CONFIG_HOME="${HOME}/klipper_config" + +if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then + KLIPPER_HOME="/usr/share/klipper" + MOONRAKER_HOME="/usr/data/moonraker/moonraker" + KLIPPER_CONFIG_HOME="/usr/data/printer_data/config" + unset OCTOPRINT_KLIPPER_CONFIG_HOME + unset OLD_KLIPPER_CONFIG_HOME +elif [ "$OS_TYPE" = "$OS_FLYOS_FAST" ]; then + KLIPPER_HOME="/data/klipper" + MOONRAKER_HOME="/data/moonraker" + KLIPPER_CONFIG_HOME="/usr/share/printer_data/config" + unset OCTOPRINT_KLIPPER_CONFIG_HOME + unset OLD_KLIPPER_CONFIG_HOME +fi + +clear +set -e # Exit immediately on error + +declare -A PIN 2>/dev/null || { + echo "Please run this script with bash $0" + exit 1 +} + +# Source pin defs for common MCU's +source ${SCRIPTPATH}/pin_defs + +# These pins will usually be on main mcu for wiring simplification +# +_hw_toolhead_sensor_pin="" +_hw_extruder_sensor_pin="" +_hw_gantry_servo_pin="" +_hw_sync_feedback_tension_pin="" +_hw_sync_feedback_compression_pin="" + +# Screen Colors +OFF='\033[0m' # Text Reset +BLACK='\033[0;30m' # Black +RED='\033[0;31m' # Red +GREEN='\033[0;32m' # Green +YELLOW='\033[0;33m' # Yellow +BLUE='\033[0;34m' # Blue +PURPLE='\033[0;35m' # Purple +CYAN='\033[0;36m' # Cyan +WHITE='\033[0;37m' # White + +B_RED='\033[1;31m' # Bold Red +B_GREEN='\033[1;32m' # Bold Green +B_YELLOW='\033[1;33m' # Bold Yellow +B_CYAN='\033[1;36m' # Bold Cyan +B_WHITE='\033[1;37m' # Bold White + +TITLE="${B_WHITE}" +DETAIL="${BLUE}" +INFO="${CYAN}" +EMPHASIZE="${B_CYAN}" +ERROR="${B_RED}" +WARNING="${B_YELLOW}" +PROMPT="${CYAN}" +DIM="${PURPLE}" +INPUT="${OFF}" +SECTION="----------------\n" + +get_logo() { + caption=$1 + logo=$(cat < /dev/null; then + # On a branch (if using tags we will be detached) + git pull --quiet --force + fi + GIT_VER=$(git describe --tags) + echo -e "${B_GREEN}Now on git version ${GIT_VER}" + echo -e "${B_GREEN}Running the new install script..." + cd - >/dev/null + exec "$SCRIPTNAME" "${ARGS[@]}" + exit 0 # Exit this old instance + fi + GIT_VER=$(git describe --tags) + echo -e "${B_GREEN}Already the latest version: ${GIT_VER}" +} + +function nextfilename { + local name="$1" + if [ -d "${name}" ]; then + printf "%s-%s" ${name} $(date '+%Y%m%d_%H%M%S') + else + printf "%s-%s.%s-old" ${name%.*} $(date '+%Y%m%d_%H%M%S') ${name##*.} + fi +} + +function nextsuffix { + local name="$1" + local -i num=0 + while [ -e "$name.0$num" ]; do + num+=1 + done + printf "%s.0%d" "$name" "$num" +} + +verify_not_root() { + if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then + echo -e "${WARNING}This script is run on a ${OS_TYPE} system, so we want it to be run as root" + return + elif [ "$OS_TYPE" = "$OS_FLYOS_FAST" ]; then + echo -e "${WARNING}This script is run on a ${OS_TYPE} system, so we want it to be run as root" + return + else + if [ "$EUID" -eq 0 ]; then + echo -e "${ERROR}This script must not run as root" + exit -1 + fi + fi +} + +check_klipper() { + if [ "$NOSERVICE" -ne 1 ]; then + if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then + # There is no systemd on MIPS, we can only check the running processes + running_klipper_pid=$(ps -o pid,comm,args | grep [^]]/klipper/klippy/klippy.py | awk '{print $1}') + KLIPPER_PID_FILE=/var/run/klippy.pid + + if [ $(cat $KLIPPER_PID_FILE) = $running_klipper_pid ]; then + echo -e "${DIM}Klipper service found" + else + echo -e "${ERROR}Klipper service not found! Please install Klipper first" + exit -1 + fi + else + if [ "$(systemctl list-units --full -all -t service --no-legend | grep -F "${KLIPPER_SERVICE}")" ]; then + echo -e "${DIM}Klipper ${KLIPPER_SERVICE} systemd service found" + else + echo -e "${ERROR}Klipper ${KLIPPER_SERVICE} systemd service not found! Please install Klipper first" + exit -1 + fi + fi + fi +} + +check_octoprint() { + if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then + OCTOPRINT=0 # Octoprint can not be set up on MIPS + elif [ "$OS_TYPE" = "$OS_FLYOS_FAST" ]; then + OCTOPRINT=0 # Octoprint can not be set up on FlyOS-Fast + elif [ "$NOSERVICE" -ne 1 ]; then + if [ "$(sudo systemctl list-units --full -all -t service --no-legend | grep -F "octoprint.service")" ]; then + echo -e "${DIM}OctoPrint service found" + OCTOPRINT=1 + else + OCTOPRINT=0 + fi + fi +} + +verify_home_dirs() { + if [ ! -d "${KLIPPER_HOME}" ]; then + echo -e "${ERROR}Klipper home directory (${KLIPPER_HOME}) not found. Use '-k ' option to override" + exit -1 + fi + if [ ! -d "${KLIPPER_CONFIG_HOME}" ]; then + if [ ! -d "${OLD_KLIPPER_CONFIG_HOME}" ]; then + if [ ! -f "${OCTOPRINT_KLIPPER_CONFIG_HOME}/${PRINTER_CONFIG}" ]; then + echo -e "${ERROR}Klipper config directory (${KLIPPER_CONFIG_HOME} or ${OLD_KLIPPER_CONFIG_HOME}) not found. Use '-c ' option to override" + exit -1 + fi + KLIPPER_CONFIG_HOME="${OCTOPRINT_KLIPPER_CONFIG_HOME}" + else + KLIPPER_CONFIG_HOME="${OLD_KLIPPER_CONFIG_HOME}" + fi + fi + echo -e "${DIM}Klipper config directory (${KLIPPER_CONFIG_HOME}) found" + + if [ ! -d "${MOONRAKER_HOME}" ]; then + if [ "${OCTOPRINT}" -eq 0 ]; then + echo -e "${ERROR}Moonraker home directory (${MOONRAKER_HOME}) not found. Use '-m ' option to override" + exit -1 + fi + echo -e "${WARNING}Moonraker home directory (${MOONRAKER_HOME}) not found. OctoPrint detected, skipping." + fi +} + +# Silently cleanup any potentially old klippy modules +cleanup_old_klippy_modules() { + if [ -d "${KLIPPER_HOME}/klippy/extras" ]; then + for file in mmu.py mmu_toolhead.py mmu_config_setup.py; do + rm -f "${KLIPPER_HOME}/klippy/extras/${file}" + done + fi +} + +link_mmu_plugins() { + echo -e "${INFO}Linking mmu extensions to Klipper..." + if [ -d "${KLIPPER_HOME}/klippy/extras" ]; then + mkdir -p "${KLIPPER_HOME}/klippy/extras/mmu" + for dir in extras extras/mmu; do + for file in ${SRCDIR}/${dir}/*.py; do + ln -sf "$file" "${KLIPPER_HOME}/klippy/${dir}/$(basename "$file")" + done + done + else + echo -e "${WARNING}Klipper extensions not installed because Klipper 'extras' directory not found!" + fi + + echo -e "${INFO}Linking mmu extension to Moonraker..." + if [ -d "${MOONRAKER_HOME}/moonraker/components" ]; then + for file in `cd ${SRCDIR}/components ; ls *.py`; do + ln -sf "${SRCDIR}/components/${file}" "${MOONRAKER_HOME}/moonraker/components/${file}" + done + else + echo -e "${WARNING}Moonraker extensions not installed because Moonraker 'components' directory not found!" + fi +} + +unlink_mmu_plugins() { + echo -e "${INFO}Unlinking mmu extensions from Klipper..." + if [ -d "${KLIPPER_HOME}/klippy/extras" ]; then + for dir in extras extras/mmu; do + for file in ${SRCDIR}/${dir}/*.py; do + rm -f "${KLIPPER_HOME}/klippy/${dir}/$(basename "$file")" + done + done + rm -rf "${KLIPPER_HOME}/klippy/extras/mmu" + else + echo -e "${WARNING}MMU modules not uninstalled because Klipper 'extras' directory not found!" + fi + + echo -e "${INFO}Unlinking mmu extension from Moonraker..." + if [ -d "${MOONRAKER_HOME}/moonraker/components" ]; then + for file in `cd ${SRCDIR}/components ; ls *.py`; do + rm -f "${MOONRAKER_HOME}/moonraker/components/${file}" + done + else + echo -e "${WARNING}MMU modules not uninstalled because Moonraker 'components' directory not found!" + fi +} + +# Parse file config settings into memory +parse_file() { + file="$1" + prefix_filter="$2" + namespace="$3" + merge="$4" + + if [ ! -f "${file}" ]; then + return + fi + + # Read old config files + while IFS= read -r line + do + # Remove leading spaces, comments and config sections + line="${line#"${line%%[![:space:]]*}"}" + line="${line%%#*}" + line="${line%%[*}" + line="${line%%;*}" + + # Check if line is not empty and contains variable or parameter + if [ ! -z "$line" ] && { [ -z "$prefix_filter" ] || [[ "$line" =~ ^($prefix_filter) ]]; }; then + # Split the line into parameter and value + IFS=":=" read -r parameter value <<< "$line" + + # Remove leading and trailing whitespace + parameter=$(echo "$parameter" | xargs) + # Need to be more careful with value because it can be quoted + value=$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + + # If parameter is one of interest and it has a value remember it + if echo "$parameter" | grep -E -q "${prefix_filter}"; then + if [ "${value}" != "" ]; then + combined="${namespace}${parameter}" + if [ ! -z "${!combined+x}" ]; then + if [ "${merge}" == "merge" ]; then + continue # Use existing value + elif [ "${merge}" == "checkdup" ]; then + echo -e "${ERROR}${parameter} defined multiple times!" + fi + fi + # Set/overwrite value in memory + if echo "$value" | grep -q '^{ .*}$'; then + # Special case drying_data dict format. This is fragile, can't wait for v4 to launch! + eval "${combined}=\"${value}\"" + elif echo "$value" | grep -q '^{.*}$'; then + eval "${combined}=\$${value}" + elif [ "${value%"${value#?}"}" = "'" ]; then + eval "${combined}=\'${value}\'" + else + eval "${combined}='${value}'" + fi + fi + fi + fi + done < "${file}" +} + +# Copy config file substituting in memory values from past config or initial interview +update_copy_file() { + src="$1" + dest="$2" + prefix_filter="$3" + namespace="$4" + + # Read the file line by line + while IFS="" read -r line || [ -n "$line" ] + do + if echo "$line" | grep -E -q '^[[:space:]]*#'; then + # Just copy simple comments + echo "$line" + elif [ ! -z "$line" ] && { [ -z "$prefix_filter" ] || [[ "$line" =~ ^($prefix_filter) ]]; }; then + # Line of interest + # Split the line into the part before # and the part after # + parameterAndValueAndSpace=$(echo "$line" | sed 's/^[[:space:]]*//' | sed 's/;/# /' | cut -d'#' -f1) + + comment="" + if echo "$line" | grep -q "#"; then + commentChar="#" + comment=$(echo "$line" | sed 's/[^#]*#//') + elif echo "$line" | grep -q ";"; then + commentChar=";" + comment=$(echo "$line" | sed 's/[^;]*;//') + fi + space=`printf "%s" "$parameterAndValueAndSpace" | sed 's/.*[^[:space:]]\(.*\)$/\1/'` + + if echo "$parameterAndValueAndSpace" | grep -E -q "${prefix_filter}"; then + # If parameter and value exist, substitute the value with the in memory variable of the same name + if echo "$parameterAndValueAndSpace" | grep -E -q '^\['; then + echo "$line" + elif [ -n "$parameterAndValueAndSpace" ]; then + parameter=$(echo "$parameterAndValueAndSpace" | cut -d':' -f1) + value=$(echo "$parameterAndValueAndSpace" | cut -d':' -f2) + if [ -n "${namespace}${parameter}" ]; then + # If 'parameter' is set and not empty, evaluate its value + new_value=$(eval echo "\$${namespace}${parameter}") + if [ -n "${namespace}" ]; then + # Namespaced, use once + eval unset ${namespace}${parameter} + fi + elif [ -n "${parameter}" ]; then + # Try non-namespaced name, multi-use + new_value=$(eval echo "\$${parameter}") + else + # If 'parameter' is unset or empty leave as token + new_value="{$parameter}" + fi + if [ -z "$new_value" ]; then + new_value="''" + fi + if [ -n "$comment" ]; then + echo "${parameter}: ${new_value}${space}${commentChar}${comment}" + else + echo "${parameter}: ${new_value}" + fi + else + echo "$line" + fi + else + echo "$line" + fi + else + # Just copy simple comments + echo "$line" + fi + done < "$src" >"$dest" +} + +# Get MMU type info first +read_previous_mmu_type() { + HAS_SELECTOR="yes" + dest_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" + if [ -f "${dest_cfg}" ]; then + if ! grep -q "^\[stepper_mmu_selector.*\]" "${dest_cfg}"; then + HAS_SELECTOR="no" + fi + fi + HAS_SERVO="yes" + dest_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" + if [ -f "${dest_cfg}" ]; then + if ! grep -q "^\[mmu_servo selector_servo\]" "${dest_cfg}" && ! grep -q "^\[mmu_servo mmu_servo\]" "${dest_cfg}"; then + HAS_SERVO="no" + fi + fi + HAS_ENCODER="yes" + dest_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" + if [ -f "${dest_cfg}" ]; then + if ! grep -q "^\[mmu_encoder mmu_encoder\]" "${dest_cfg}"; then + HAS_ENCODER="no" + fi + fi + HAS_ESPOOLER="yes" + dest_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" + if [ -f "${dest_cfg}" ]; then + if ! grep -q "^\[mmu_espooler mmu_espooler\]" "${dest_cfg}"; then + HAS_ESPOOLER="no" + fi + fi + + # Figure out the selector type based on h/w presence + if [ "$HAS_SELECTOR" == "no" -a "$HAS_SERVO" == "no" ]; then + _hw_selector_type='VirtualSelector' + elif [ "$HAS_SELECTOR" == "no" -a "$HAS_SERVO" == "yes" ]; then + _hw_selector_type='ServoSelector' + elif [ "$HAS_SELECTOR" == "yes" -a "$HAS_SERVO" == "no" ]; then + _hw_selector_type='RotarySelector' + else + _hw_selector_type='LinearSelector' + fi + echo -e "${INFO}HAS_SELECTOR=${HAS_SELECTOR}" + echo -e "${INFO}HAS_SERVO=${HAS_SERVO}" + echo -e "${INFO}HAS_ENCODER=${HAS_ENCODER}" + echo -e "${INFO}HAS_ESPOOLER=${HAS_ESPOOLER}" + echo -e "${INFO}Determined you have a ${_hw_selector_type} or similar" +} + +# Set default parameters from the distribution (reference) config files +read_default_config() { + if [ "$1" == "merge" ]; then + echo -e "${INFO}Merging default configuration parameters..." + merge="merge" + else + echo -e "${INFO}Reading default configuration parameters..." + merge="checkdup" + fi + + if [ "$HAS_SELECTOR" == "no" -a "$HAS_SERVO" == "no" ]; then + parse_file "${SRCDIR}/config/base/mmu_parameters.cfg.vs" "" "_param_" "$merge" + elif [ "$HAS_SELECTOR" == "no" -a "$HAS_SERVO" == "yes" ]; then + parse_file "${SRCDIR}/config/base/mmu_parameters.cfg.ss" "" "_param_" "$merge" + elif [ "$HAS_SELECTOR" == "yes" -a "$HAS_SERVO" == "no" ]; then + parse_file "${SRCDIR}/config/base/mmu_parameters.cfg.rs" "" "_param_" "$merge" + else + # All other selector types + parse_file "${SRCDIR}/config/base/mmu_parameters.cfg" "" "_param_" "$merge" + fi + parse_file "${SRCDIR}/config/base/mmu_macro_vars.cfg" "variable_|filename" "" "$merge" + for file in `cd ${SRCDIR}/config/addons ; ls *.cfg | grep -v "_hw" | grep -v "my_"`; do + parse_file "${SRCDIR}/config/addons/${file}" "variable_" "" "$merge" + done +} + +# Pull parameters from previous installation +read_previous_config() { + + # Get a few vital bits of information stored in mmu_hardware.cfg if available + cfg="mmu_hardware.cfg" + dest_cfg=${KLIPPER_CONFIG_HOME}/mmu/base/${cfg} + if [ -f "${dest_cfg}" ]; then + num_gates=$(sed -n 's/^num_gates[[:space:]]*[:=][[:space:]]*\([0-9,[:space:]]*\)\([[:space:]]*#.*\)\{0,1\}$/\1/p' "${dest_cfg}") + num_gates="${num_gates// /}" + IFS=', ' read -r -a gates_array <<< "$num_gates" + _hw_num_gates=0 + for gate in "${gates_array[@]}"; do + ((_hw_num_gates += gate)) + done + fi + + cfg="mmu_parameters.cfg" + dest_cfg=${KLIPPER_CONFIG_HOME}/mmu/base/${cfg} + if [ -f "${dest_cfg}" -a "$_hw_num_gates" == "" ]; then + _hw_num_gates=$(sed -n 's/^mmu_num_gates[[:space:]]*[:=][[:space:]]*\([0-9]\{1,\}\)[[:space:]]*.*$/\1/p' "${dest_cfg}") + fi + + if [ ! -f "${dest_cfg}" ]; then + echo -e "${WARNING}No previous ${cfg} found. Will install default" + else + echo -e "${INFO}Reading ${cfg} configuration from previous installation..." + parse_file "${dest_cfg}" "" "_param_" + fi + + for cfg in mmu_macro_vars.cfg; do + dest_cfg=${KLIPPER_CONFIG_HOME}/mmu/base/${cfg} + if [ ! -f "${dest_cfg}" ]; then + echo -e "${WARNING}No previous ${cfg} found. Will install default" + else + echo -e "${INFO}Reading ${cfg} configuration from previous installation..." + if [ "${cfg}" == "mmu_macro_vars.cfg" ]; then + parse_file "${dest_cfg}" "variable_|filename" + else + parse_file "${dest_cfg}" "variable_" + fi + fi + done + + # TODO namespace config in third-party addons separately + if [ -d "${KLIPPER_CONFIG_HOME}/mmu/addons" ]; then + for cfg in `cd ${KLIPPER_CONFIG_HOME}/mmu/addons ; ls *.cfg | grep -v "_hw"`; do + dest_cfg=${KLIPPER_CONFIG_HOME}/mmu/addons/${cfg} + if [ ! -f "${dest_cfg}" ]; then + echo -e "${WARNING}No previous ${cfg} found. Will install default" + else + echo -e "${INFO}Reading ${cfg} configuration from previous installation..." + parse_file "${dest_cfg}" "variable_" + fi + done + fi + + # Upgrade / map / force old parameters... + # v2.7.1 + if [ ! "${variable_pin_park_x_dist}" == "" ]; then + variable_pin_park_dist="${variable_pin_park_x_dist}" + fi + if [ ! "${variable_pin_loc_x_compressed}" == "" ]; then + variable_pin_loc_compressed="${variable_pin_loc_x_compressed}" + fi + if [ ! "${variable_park_xy}" == "" ]; then + variable_park_toolchange="${variable_park_xy}, ${_param_z_hop_height_toolchange:-0}, 0, 2" + variable_park_error="${variable_park_xy}, ${_param_z_hop_height_error:-0}, 0, 2" + fi + if [ ! "${variable_lift_speed}" == "" ]; then + variable_park_lift_speed="${variable_lift_speed}" + fi + + if [ "${variable_enable_park}" == "False" ]; then + variable_enable_park_printing="'pause,cancel'" + if [ "${variable_enable_park_runout}" == "True" ]; then + variable_enable_park_printing="'toolchange,load,unload,runout,pause,cancel'" + fi + elif [ "${variable_enable_park_printing}" == "" ]; then + variable_enable_park_printing="'toolchange,load,unload,pause,cancel'" + fi + + if [ "${variable_enable_park_standalone}" == "False" ]; then + variable_enable_park_standalone="'pause,cancel'" + elif [ "${variable_enable_park_standalone}" == "" ]; then + variable_enable_park_standalone="'toolchange,load,unload,pause,cancel'" + fi + + # v2.7.2 + if [ "${_param_toolhead_residual_filament}" == "0" -a ! "${_param_toolhead_ooze_reduction}" == "0" ]; then + _param_toolhead_residual_filament=$_param_toolhead_ooze_reduction + _param_toolhead_ooze_reduction=0 + fi + + # v2.7.3 - Blobifer update - Oct 13th 20204 + if [ ! "${variable_iteration_z_raise}" == "" ]; then + echo -e "${INFO}Setting Blobifier variable_z_raise and variable_purge_length_maximum from previous settings" + variable_z_raise=$(awk -v iter_z_raise="$variable_iteration_z_raise" -v max_iter="$variable_max_iterations_per_blob" -v z_change="$variable_iteration_z_change" 'BEGIN { + triangular_value = (max_iter - 1) * max_iter / 2; + print iter_z_raise * max_iter - triangular_value * z_change; + }') + variable_purge_length_maximum=$(awk -v max_len="$variable_max_iteration_length" -v max_iter="$variable_max_iterations_per_blob" 'BEGIN { print max_len * max_iter }') + fi + + # v3.0.0 + if [ "${_param_auto_calibrate_gates}" != "" ]; then + _param_autotune_rotation_distance=${_param_auto_calibrate_gates} + fi + if [ "${_param_auto_calibrate_bowden}" != "" ]; then + _param_autotune_bowden_length=${_param_auto_calibrate_bowden} + fi + if [ "${_param_endless_spool_final_eject}" != "" ]; then + _param_gate_final_eject_distance=${_param_endless_spool_final_eject} + fi + if [ "${variable_eject_tool}" != "" ]; then + variable_unload_tool=${variable_eject_tool} + fi + if [ "${variable_eject_tool_on_cancel}" != "" ]; then + variable_unload_tool_on_cancel=${variable_eject_tool_on_cancel} + fi + + # v3.0.2 + if [ "${_param_homing_extruder}" != "" ]; then + _hw_homing_extruder=${_param_homing_extruder} + fi + + # v3.1.0 + if [ "${variable_pin_loc_compressed}" != "" ]; then + echo -e "${INFO}Upgrading variable_pin_loc_compressed --> variable_pin_loc_compressed_xy" + pin_loc_x=$(echo ${variable_pin_loc_xy} | cut -d ',' -f1) + pin_loc_y=$(echo ${variable_pin_loc_xy} | cut -d ',' -f2) + if expr "${variable_cutting_axis}" : '.*x.*' >/dev/null; then + variable_pin_loc_compressed_xy="${variable_pin_loc_compressed}, ${pin_loc_y}" + else + variable_pin_loc_compressed_xy="${pin_loc_x}, ${variable_pin_loc_compressed}" + fi + fi + + # v3.2.0 + # eSpooler move from macro to mmu_parameters + if [ "${variable_max_step_speed}" != "" ]; then + _param_espooler_max_stepper_speed=${variable_max_step_speed} + fi + if [ "${variable_min_distance}" != "" ]; then + _param_espooler_min_distance=${variable_min_distance} + fi + if [ "${variable_step_speed_exponent}" != "" ]; then + _param_espooler_speed_exponent=${variable_step_speed_exponent} + fi + # Change -1 to -999 for no park move (allows for negative movement in user macros) + if ! check_for_999 \ + "${variable_park_toolchange}" \ + "${variable_park_runout}" \ + "${variable_park_pause}" \ + "${variable_park_cancel}" \ + "${variable_park_complete}" \ + "${variable_pre_unload_position}" \ + "${variable_post_form_tip_position}" \ + "${variable_pre_load_position}" + then + variable_park_toolchange=$(convert_neg_one "${variable_park_toolchange}") + variable_park_runout=$(convert_neg_one "${variable_park_runout}") + variable_park_pause=$(convert_neg_one "${variable_park_pause}") + variable_park_cancel=$(convert_neg_one "${variable_park_cancel}") + variable_park_complete=$(convert_neg_one "${variable_park_complete}") + variable_pre_unload_position=$(convert_neg_one "${variable_pre_unload_position}") + variable_post_form_tip_position=$(convert_neg_one "${variable_post_form_tip_position}") + variable_pre_load_position=$(convert_neg_one "${variable_pre_load_position}") + fi + + # v3.2.0 + if [ "${_param_sync_feedback_enable}" != "" ]; then + _param_sync_feedback_enabled=${_param_sync_feedback_enable} + fi + + # v3.4.0 - led config moved to v4 format (from macro to python module) + # + #if [ "${variable_led_enable}" != "" ]; then + # _hw_led_enable=$(convert_boolean_string_to_int "${variable_led_enable}") + #fi + + # v3.4.2 - not upgraded because new values will correct user adjustments + # sync_multiplier_high: 1.05 + # sync_multiplier_low: 0.95 + # >> sync_feedback_speed_multiplier: 5 + # >> sync_feedback_extrude_threshold: 5 + # v3.4.2 - name rationalization + # selector_touch_enable >> selector_touch_enabled + # enable_clog_detection >> flowguard_encoder_mode + # enable_endless_spool >> endless_spool_enabled + if [ "${_param_selector_touch_enable}" != "" ]; then + _param_selector_touch_enabled=${_param_selector_touch_enable} + fi + if [ "${_param_enable_clog_detection}" != "" ]; then + _param_flowguard_encoder_mode=${_param_enable_clog_detection} + fi + if [ "${_param_enable_endless_spool}" != "" ]; then + _param_endless_spool_enabled=${_param_enable_endless_spool} + fi +} + +check_for_999() { + for var in "$@"; do + if echo "${var}" | grep -q "\-999"; then + return 0 + fi + done + return 1 +} + +convert_neg_one() { + echo "$1" | awk -F',' ' + { + if ($1 ~ /^ *-1 *$/) $1 = " -999"; + if ($2 ~ /^ *-1 *$/) $2 = " -999"; + OFS="," + for (i=1; i<=NF; i++) { + printf "%s%s", $i, (i/dev/null; then + echo "True" + elif [ "$1" -eq 0 ] 2>/dev/null; then + echo "False" + else + echo "$1" + fi +} + +convert_boolean_string_to_int() { + if [ "$1" == "True" ] 2>/dev/null; then + echo 1 + elif [ "$1" == "False" ] 2>/dev/null; then + echo 0 + else + echo "$1" + fi +} + +# I'd prefer not to attempt to upgrade mmu_hardware.cfg but these will ease pain +# and are relatively safe +upgrade_mmu_hardware() { + echo -e "${INFO}Checking for need to upgrade mmu_hardware.cfg..." + + hardware_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" + + # v3.0.0: Upgrade mmu_servo to mmu_selector_servo + found_mmu_servo=$(grep -E -c "^\[mmu_servo mmu_servo\]" ${hardware_cfg} || true) + if [ "${found_mmu_servo}" -eq 1 ]; then + sed "s/\[mmu_servo mmu_servo\]/\[mmu_servo selector_servo\]/g" "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" ${hardware_cfg} + echo -e "${INFO}Updated [mmu_servo mmu_servo] in mmu_hardware.cfg..." + fi + + found_mmu_machine=$(grep -E -c "^\[mmu_machine\]" ${hardware_cfg} || true) + + # v3.0.0: Remove num_gates in led section + found_num_gates=$(grep -E -c "^(#?num_gates)" ${hardware_cfg} || true) + if [ "${found_num_gates}" -gt 0 -a "${found_mmu_machine}" -eq 0 ]; then + sed "/^\(#\?num_gates\)/d" "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" ${hardware_cfg} + echo -e "${INFO}Removed 'num_gates' from [mmu_leds] section in mmu_hardware.cfg..." + fi + + # v3.0.0: Add minimal [mmu_machine] section as first section + if [ "${found_mmu_machine}" -eq 0 ]; then + + # Note params will be comming from mmu_parameters + new_section=$(cat < "$temp_file" + awk ' + BEGIN { found = 0 } + /^[[:space:]]*$/ && !found { + print + while ((getline line < "'"$temp_file"'") > 0) print line + close("'"$temp_file"'") + found = 1 + next + } + { print } + ' "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" "${hardware_cfg}" + rm "$temp_file" + + echo -e "${INFO}Added new [mmu_machine] section to mmu_hardware.cfg..." + fi + + # v3.0.2: LED rework + led_strip_value=$(sed -n 's/^led_strip: \(.*\)/\1/p' "$hardware_cfg") + if [ ! "$led_strip_value" == "" ]; then + sed -e '/^led_strip:/d' \ + -e "s|^\(#*\)\(exit_range:\) \(.*\)|\1exit_leds: ${led_strip_value} (\3)|" \ + -e "s|^\(#*\)\(entry_range:\) \(.*\)|\1entry_leds: ${led_strip_value} (\3)|" \ + -e "s|^\(#*\)\(status_index:\) \(.*\)|\1status_leds: ${led_strip_value} (\3)|" \ + "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" "${hardware_cfg}" + + echo -e "${INFO}Updated [mmu_leds] section in mmu_hardware.cfg..." + fi + + # v3.2.0: Add new [mmu_espooler] section as first section + found_mmu_espooler=$(grep -E -c "^#?\[mmu_espooler" ${hardware_cfg} || true) + found_stepper_mmu_gear_1=$(grep -E -c "^\[stepper_mmu_gear_1\]" ${hardware_cfg} || true) + if [ "${found_mmu_espooler}" -eq 0 -a "${found_stepper_mmu_gear_1}" -eq 1 ]; then + + # Note params will be coming from mmu_parameters + new_section=$(cat <> "${hardware_cfg}" + echo -e "${INFO}Added new [mmu_machine] section to mmu_hardware.cfg..." + fi + + # v3.4.0: Update [mmu_leds] section for v4 python impl + found_old_mmu_leds=$(grep -E -c "^\[mmu_leds\]" ${hardware_cfg} || true) + if [ "${found_old_mmu_leds}" -eq 1 ]; then + + sed "s/\[mmu_leds\]/\[mmu_leds unit0\]/g" "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" ${hardware_cfg} + new_section=$(cat < "$temp_file" + awk ' + BEGIN { found = 0 } + /^frame_rate/ && !found { + print + while ((getline line < "'"$temp_file"'") > 0) print line + close("'"$temp_file"'") + found = 1 + next + } + { print } + ' "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" "${hardware_cfg}" + rm "$temp_file" + echo -e "${INFO}Upgraded [mmu_leds] section in mmu_hardware.cfg with new settings..." + fi +} + +copy_config_files() { + mmu_dir="${KLIPPER_CONFIG_HOME}/mmu" + next_mmu_dir="$(nextfilename "${mmu_dir}")" + + echo -e "${INFO}Copying configuration files into ${mmu_dir} directory..." + if [ ! -d "${mmu_dir}" ]; then + mkdir ${mmu_dir} + mkdir ${mmu_dir}/base + mkdir ${mmu_dir}/optional + mkdir ${mmu_dir}/addons + else + echo -e "${DETAIL}Config directory ${mmu_dir} already exists" + echo -e "${DETAIL}Backing up old config files to ${next_mmu_dir}" + mkdir ${next_mmu_dir} + (cd "${mmu_dir}"; cp -r * "${next_mmu_dir}") + + # Ensure all new directories exist + mkdir -p ${mmu_dir}/base + mkdir -p ${mmu_dir}/optional + mkdir -p ${mmu_dir}/addons + fi + + # Now substitute tokens using given brd_type and "questionaire" starting values + : ${_hw_chain_count:=$(expr $_hw_num_gates \* 2 + 2)} + num_leds_minus1=$(expr $_hw_chain_count - 1) + num_gates_plus1=$(expr $_hw_num_gates + 1) + num_gates_mult2=$(expr $_hw_num_gates + $_hw_num_gates) + + # Comment some LEDs unless set by questionaire + vars=("_hw_entry_leds" "_hw_status_leds" "_hw_logo_leds") + for var in "${vars[@]}"; do + if [ -z "${!var+set}" ]; then + declare "_comment${var}=true" + fi + done + + # But still given suggested values even if commented + : ${_hw_exit_leds="neopixel:mmu_leds (1-${_hw_num_gates})"} + : ${_hw_entry_leds="neopixel:mmu_leds (${num_gates_plus1}-${num_gates_mult2})"} + : ${_hw_status_leds="neopixel:mmu_leds (${num_leds_minus1})"} + : ${_hw_logo_leds="neopixel:mmu_leds (${_hw_chain_count})"} + + # Find all variables that start with _hw_, substitute values and comment is necessary + for var in $(compgen -v | grep '^_hw_'); do + value=${!var} + pattern="{${var#_hw_}}" + comment="_comment${var}" + if [ "${!comment}" == "true" ]; then + sed_expr="${sed_expr}/${pattern}/ { s|^|#|; s|${pattern}|${value}|g; }; " + else + sed_expr="${sed_expr}s|${pattern}|${value}|g; " + fi + done + + # Find all variables in the form of PIN[$_hw_brd_type,*] + if [ "$_hw_selector_type" == "VirtualSelector" ]; then + # Type-B MMU has alternative pin allocation + key_match="B,$_hw_brd_type" + else + key_match="$_hw_brd_type" + fi + for key in "${!PIN[@]}"; do + if [[ $key == "$key_match"* ]]; then + value="${PIN[$key]}" + pin_var=$(echo "$key" | sed "s/^$key_match,//") + pattern="{${pin_var}}" + sed_expr="${sed_expr}s|${pattern}|${value}|g; " + fi + done + + for file in `cd ${SRCDIR}/config/base ; ls *.cfg`; do + src=${SRCDIR}/config/base/${file} + dest=${mmu_dir}/base/${file} + next_dest=${next_mmu_dir}/base/${file} + + if [ -f "${dest}" ]; then + if [ "${file}" == "mmu_hardware.cfg" -a "${INSTALL}" -eq 0 ] || [ "${file}" == "mmu.cfg" -a "${INSTALL}" -eq 0 ]; then + echo -e "${WARNING}Skipping copy of hardware config file ${file} because already exists" + continue + else + if [ "${file}" == "mmu_parameters.cfg" ] || [ "${file}" == "mmu_macro_vars.cfg" ]; then + echo -e "${INFO}Upgrading configuration file ${file}" + else + echo -e "${INFO}Installing configuration file ${file}" + fi + mv ${dest} ${next_dest} # Backup old config file + fi + fi + + # Hardware files: Special token substitution ----------------------------------------- + if [ "${file}" == "mmu.cfg" -o "${file}" == "mmu_hardware.cfg" ]; then + + # Kludge to support complete h/w configurations for dedicated MMUs + if [ "${_hw_mmu_vendor}" == "KMS" -o "${_hw_mmu_vendor}" == "VVD" ]; then + if [ "${_hw_mmu_vendor}" == "KMS" ]; then + cp "${src}.kms" ${dest} + else + cp "${src}.vvd" ${dest} + fi + + # Do all the token substitution + cat ${dest} | sed -e "$sed_expr" "${dest}" > "${dest}.tmp" > ${dest}.tmp && mv ${dest}.tmp ${dest} + + # Skip the rest because config was preconfigured + continue + else + cp ${src} ${dest} + fi + + # Correct shared uart_address for EASY-BRD + if [ "${_hw_brd_type}" == "EASY-BRD" ]; then + # Share uart_pin to avoid duplicate alias problem + cat ${dest} | sed -e "\ + s/^uart_pin: mmu:MMU_SEL_UART/uart_pin: mmu:MMU_GEAR_UART/; \ + " > ${dest}.tmp && mv ${dest}.tmp ${dest} + elif [ "${_hw_brd_type}" == "SKR_PICO_1" ]; then + # Share uart_pin to avoid duplicate alias problem + cat ${dest} | sed -e "\ + s/^uart_pin: mmu:MMU_SEL_UART/uart_pin: mmu:MMU_GEAR_UART/; \ + " > ${dest}.tmp && mv ${dest}.tmp ${dest} + else + # Remove uart_address lines + cat ${dest} | sed -e "\ + /^uart_address:/ d; \ + " > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + + if [ "${SETUP_SELECTOR_TOUCH}" == "yes" ]; then + cat ${dest} | sed -e "\ + s/^#\(diag_pin: \^mmu:MMU_SEL_DIAG\)/\1/; \ + s/^#\(driver_SGTHRS: 75\)/\1/; \ + s/^#\(extra_endstop_pins: tmc2209_stepper_mmu_selector:virtual_endstop\)/\1/; \ + s/^#\(extra_endstop_names: mmu_sel_touch\)/\1/; \ + s/^uart_address:/${uart_comment}uart_address:/; \ + " > ${dest}.tmp && mv ${dest}.tmp ${dest} + + elif [ "${SETUP_SELECTOR_STALLGUARD_HOMING}" == "yes" ]; then + cat ${dest} | sed -e "\ + s/^#\(diag_pin: \^mmu:MMU_SEL_DIAG\)/\1/; \ + s/^#\(driver_SGTHRS: 75\)/\1/; \ + s/^endstop_pin: ^mmu:MMU_SEL_ENDSTOP.*$/endstop_pin: tmc2209_stepper_mmu_selector:virtual_endstop/; \ + s/^#\(homing_retract_dist\)/\1/; \ + " > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + + # Do all the token substitution + cat ${dest} | sed -e "$sed_expr" "${dest}" > "${dest}.tmp" > ${dest}.tmp && mv ${dest}.tmp ${dest} + + # Handle LED option - Comment out if disabled (section is last, go comment to end of file) + if [ "${file}" == "mmu_hardware.cfg" -a "$SETUP_LED" == "no" ]; then + sed '/^\[\(neopixel mmu_leds\|mmu_leds\)\]/,${ /^[^#]/ s/^/#/ }' "${dest}" > "${dest}.tmp" && mv "${dest}.tmp" "${dest}" + fi + + # Handle Encoder option - Comment out if not fitted so can easily be added later + if [ "${file}" == "mmu_hardware.cfg" -a "$HAS_ENCODER" == "no" ]; then + sed "/^\[mmu_encoder mmu_encoder\]/,+6 {/^[^#]/ s/^/#/}" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} + #sed "/^# ENCODER/,+24 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + + # Handle Espooler option - Comment out if not fitted so can easily be added later + if [ "${file}" == "mmu_hardware.cfg" -a "$HAS_ESPOOLER" == "no" ]; then + sed "/^\[mmu_espooler mmu_espooler\]/,+27 {/^[^#]/ s/^/#/}" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} + #sed "/^# ESPOOLER/,+41 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + + # Handle Selector options - Delete if not required (sections are 8 and 38 lines respectively) + if [ "${file}" == "mmu_hardware.cfg" ]; then + if [ "$HAS_SELECTOR" == "no" ]; then + sed "/^# SELECTOR STEPPER/,+37 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + + if [ "$HAS_SERVO" == "no" ]; then + sed "/^# SELECTOR SERVO/,+7 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + + if [ "$_hw_selector_type" == "VirtualSelector" ]; then + # Expand out the additional filament drive for each gate + additional_gear_section=$(sed -n "/^# ADDITIONAL FILAMENT DRIVE/,+10 p" ${dest} | sed "1,3d") + awk '{ print } /^# ADDITIONAL FILAMENT DRIVE/ { for (i=1; i<=11; i++) { getline; print }; exit }' ${dest} > ${dest}.tmp + for (( i=2; i<=$(expr $_hw_num_gates - 1); i++ )) + do + echo "$(echo "${additional_gear_section}" | sed "s/_1/_$i/g")" >> ${dest}.tmp + echo >> ${dest}.tmp + done + awk '/^# ADDITIONAL FILAMENT DRIVE/ {flag=1; count=0} flag && count++ >= 12 {print}' ${dest} >> ${dest}.tmp && mv ${dest}.tmp ${dest} + if [ "${_hw_brd_type}" == "SKR_PICO_1" ]; then + # Remove duplicate uart_pin's and add proper uart_addresses + cat ${dest} | sed -e "s/^uart_pin: mmu:MMU_GEAR_UART_1/uart_pin: mmu:MMU_GEAR_UART\nuart_address: 2/" > ${dest}.tmp && mv ${dest}.tmp ${dest} + cat ${dest} | sed -e "s/^uart_pin: mmu:MMU_GEAR_UART_2/uart_pin: mmu:MMU_GEAR_UART\nuart_address: 1/" > ${dest}.tmp && mv ${dest}.tmp ${dest} + cat ${dest} | sed -e "s/^uart_pin: mmu:MMU_GEAR_UART_3/uart_pin: mmu:MMU_GEAR_UART\nuart_address: 3/" > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + else + # Delete additional gear drivers template section + sed "/^# ADDITIONAL FILAMENT DRIVE/,+10 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + fi + + # Configuration parameters ----------------------------------------------------------- + elif [ "${file}" == "mmu_parameters.cfg" ]; then + if [ "$_hw_selector_type" == "VirtualSelector" ] ; then + # Use truncated VirtualSelector parameter file (no selector, no servo) + update_copy_file "${src}.vs" "$dest" "" "_param_" + elif [ "$_hw_selector_type" == "ServoSelector" ] ; then + # Use truncated ServoSelector parameter file (no selector, with servo) + update_copy_file "${src}.ss" "$dest" "" "_param_" + elif [ "$_hw_selector_type" == "RotarySelector" ] ; then + # Use truncated RotarySelector parameter file (with selector, no servo) + update_copy_file "${src}.rs" "$dest" "" "_param_" + else + update_copy_file "$src" "$dest" "" "_param_" + if [ "$HAS_SERVO" == "no" ]; then + # Remove selector servo section + sed "/^# Servo configuration/,+27 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} + fi + fi + + # Ensure that supplemental user added params are retained. These are those that are + # by default set internally in Happy Hare based on vendor and version settings but + # can be overridden. This set also includes a couple of hidden test parameters. + echo "" >> $dest + echo "# SUPPLEMENTAL USER CONFIG retained after upgrade --------------------------------------------------------------------" >> $dest + echo "#" >> $dest + supplemental_params="cad_gate0_pos cad_gate_width cad_bypass_offset cad_last_gate_offset cad_block_width cad_bypass_block_width cad_bypass_block_delta cad_selector_tolerance gate_material gate_color gate_spool_id gate_status gate_filament_name gate_temperature gate_speed_override endless_spool_groups tool_to_gate_map" + hidden_params="test_random_failures test_random_failures test_disable_encoder test_force_in_print serious suppress_kalico_warning" + for var in $(set | grep '^_param_' | cut -d'=' -f1 | sort); do + param=${var#_param_} + for item in ${supplemental_params} ${hidden_params}; do + if [ "$item" = "$param" ]; then + value=$(eval echo "\$${var}") + echo "${param}: ${value}" + eval unset ${var} + fi + done + done >> $dest + + # If any params are still left warn the user because they will be lost (should have been upgraded) + for var in $(set | grep '^_param_' | cut -d= -f1); do + param=${var#_param_} + value=$(eval echo \$$var) + echo "Parameter: '$param: $value' is not required or deprecated and has been removed" + done + + # Variables macro --------------------------------------------------------------------- + elif [ "${file}" == "mmu_macro_vars.cfg" ]; then + tx_macros="" + if [ "$_hw_num_gates" == "" -o "$_hw_num_gates" == "{num_gates}" ]; then + _hw_num_gates=12 + fi + for (( i=0; i<=$(expr $_hw_num_gates - 1); i++ )) + do + tx_macros+="[gcode_macro T${i}]\n" + tx_macros+="gcode: MMU_CHANGE_TOOL TOOL=${i}\n" + done + + if [ "${INSTALL}" -eq 1 ]; then + cat ${src} | sed -e "\ + s%{tx_macros}%${tx_macros}%g; \ + " > ${dest} + else + cat ${src} | sed -e "\ + s%{tx_macros}%${tx_macros}%g; \ + " > ${dest}.tmp + update_copy_file "${dest}.tmp" "${dest}" "variable_|filename" && rm ${dest}.tmp + fi + + # Everything else is read-only symlink ------------------------------------------------ + else + ln -sf ${src} ${dest} + fi + done + + # Optional config are read-only symlinks -------------------------------------------------- + for file in `cd ${SRCDIR}/config/optional ; ls *.cfg`; do + src=${SRCDIR}/config/optional/${file} + dest=${mmu_dir}/optional/${file} + ln -sf ${src} ${dest} + done + + # Don't stomp on existing persisted state ------------------------------------------------ + src=${SRCDIR}/config/mmu_vars.cfg + dest=${mmu_dir}/mmu_vars.cfg + if [ -f "${dest}" ]; then + echo -e "${WARNING}Skipping copy of mmu_vars.cfg file because already exists" + else + cp ${src} ${dest} + fi + + # Addon config files are always copied (and updated) so they can be edited ---------------- + # Skipping files with 'my_' prefix for development + for file in `cd ${SRCDIR}/config/addons ; ls *.cfg | grep -v "my_"`; do + src=${SRCDIR}/config/addons/${file} + dest=${mmu_dir}/addons/${file} + if [ -f "${dest}" ]; then + if ! echo "$file" | grep -E -q ".*_hw\.cfg.*"; then + echo -e "${INFO}Upgrading configuration file ${file}" + update_copy_file ${src} ${dest} "variable_" + else + echo -e "${WARNING}Skipping copy of ${file} file because already exists" + fi + else + echo -e "${INFO}Installing configuration file ${file}" + cp ${src} ${dest} + fi + done +} + +remove_old_config_files() { + mmu_dir="${KLIPPER_CONFIG_HOME}/mmu" + if [ -f "${mmu_dir}/addons/dc_espooler.cfg" ]; then + echo -e "${WARNING}Removing legacy dc_spooler macros - configuration now in mmu_hardware.cfg" + rm -f "${mmu_dir}/addons/dc_espooler.cfg" + rm -f "${mmu_dir}/addons/dc_espooler_hw.cfg" + fi +} + + +uninstall_config_files() { + if [ -d "${KLIPPER_CONFIG_HOME}/mmu" ]; then + echo -e "${INFO}Removing MMU configuration files from ${KLIPPER_CONFIG_HOME}" + mv "${KLIPPER_CONFIG_HOME}/mmu" /tmp/mmu.uninstalled + fi +} + +install_printer_includes() { + # Link in all includes if not already present + dest=${KLIPPER_CONFIG_HOME}/${PRINTER_CONFIG} + if test -f $dest; then + + klippain_included=$(grep -c "\[include config/hardware/mmu.cfg\]" ${dest} || true) + if [ "${klippain_included}" -eq 1 ]; then + echo -e "${WARNING}This looks like a Klippain config installation - skipping automatic config install. Please add config includes by hand" + else + next_dest="$(nextfilename "$dest")" + echo -e "${INFO}Copying original ${PRINTER_CONFIG} file to ${next_dest}" + cp ${dest} ${next_dest} + if [ ${ADDONS_EREC} -eq 1 ]; then + i='\[include mmu/addons/mmu_erec_cutter.cfg\]' + already_included=$(grep -c "${i}" ${dest} || true) + if [ "${already_included}" -eq 0 ]; then + sed -i "1i ${i}" ${dest} + fi + fi + if [ ${ADDONS_BLOBIFIER} -eq 1 ]; then + i='\[include mmu/addons/blobifier.cfg\]' + already_included=$(grep -c "${i}" ${dest} || true) + if [ "${already_included}" -eq 0 ]; then + sed -i "1i ${i}" ${dest} + fi + fi + if [ ${ADDONS_EJECT_BUTTONS} -eq 1 ]; then + i='\[include mmu/addons/mmu_eject_buttons.cfg\]' + already_included=$(grep -c "${i}" ${dest} || true) + if [ "${already_included}" -eq 0 ]; then + sed -i "1i ${i}" ${dest} + fi + fi + if [ ${MENU_12864} -eq 1 ]; then + i='\[include mmu/optional/mmu_menu.cfg\]' + already_included=$(grep -c "${i}" ${dest} || true) + if [ "${already_included}" -eq 0 ]; then + sed -i "1i ${i}" ${dest} + fi + fi + if [ ${CLIENT_MACROS} -eq 1 ]; then + i='\[include mmu/optional/client_macros.cfg\]' + already_included=$(grep -c "${i}" ${dest} || true) + if [ "${already_included}" -eq 0 ]; then + sed -i "1i ${i}" ${dest} + fi + fi + for i in '\[include mmu/base/\*.cfg\]' ; do + already_included=$(grep -c "${i}" ${dest} || true) + if [ "${already_included}" -eq 0 ]; then + sed -i "1i ${i}" ${dest} + fi + done + fi + else + echo -e "${WARNING}File ${PRINTER_CONFIG} file not found! Cannot include MMU configuration files" + fi +} + +uninstall_printer_includes() { + echo -e "${INFO}Cleaning MMU references from ${PRINTER_CONFIG}" + dest=${KLIPPER_CONFIG_HOME}/${PRINTER_CONFIG} + if test -f $dest; then + next_dest="$(nextfilename "$dest")" + echo -e "${INFO}Copying original ${PRINTER_CONFIG} file to ${next_dest} before cleaning" + cp ${dest} ${next_dest} + cat "${dest}" | sed -e " \ + /\[include mmu\/*.cfg\]/ d; \ + " > "${dest}.tmp" && mv "${dest}.tmp" "${dest}" + fi +} + +install_update_manager() { + echo -e "${INFO}Adding update manager to moonraker.conf" + file="${KLIPPER_CONFIG_HOME}/moonraker.conf" + if [ -f "${file}" ]; then + restart=0 + + update_section=$(grep -c '\[update_manager happy-hare\]' ${file} || true) + if [ "${update_section}" -eq 0 ] && [ "$OS_TYPE" != "$OS_FLYOS_FAST" ]; then + echo "" >> "${file}" + while read -r line; do + echo -e "${line}" >> "${file}" + done < "${SRCDIR}/moonraker_update.txt" + echo "" >> "${file}" + # The path for Happy-Hare on MIPS is /usr/data/Happy-Hare + if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then + sed -i 's|path: ~/Happy-Hare|path: /usr/data/Happy-Hare|' "${file}" + echo -e "${INFO}Update Happy-Hare path for MIPS architecture." + fi + restart=1 + else + echo -e "${WARNING}[update_manager happy-hare] already exists in moonraker.conf - skipping install" + fi + + # Quick "catch-up" update for new mmu_service + enable_preprocessor="True" + update_section=$(grep -c '\[mmu_server\]' ${file} || true) + if [ "${update_section}" -eq 0 ]; then + echo "" >> "${file}" + echo "[mmu_server]" >> "${file}" + echo "enable_file_preprocessor: ${enable_preprocessor}" >> "${file}" + echo "" >> "${file}" + restart=1 + else + echo -e "${WARNING}[mmu_server] already exists in moonraker.conf - skipping install" + fi + + # Quick "catch-up" update for new toolchange_next_pos pre-processing + update_section=$(grep -c 'enable_toolchange_next_pos' ${file} || true) + if [ "${update_section}" -eq 0 ]; then + awk '/^enable_file_preprocessor/ {print $0 "\nenable_toolchange_next_pos: True\n"; next} {print}' ${file} > ${file}.tmp && mv ${file}.tmp ${file} + restart=1 + echo -e "${WARNING}Added new 'enable_toolchange_next_pos' to moonraker.conf" + fi + + if [ "$restart" -eq 1 ]; then + restart_moonraker + fi + else + echo -e "${WARNING}moonraker.conf not found!" + fi +} + +uninstall_update_manager() { + echo -e "${INFO}Removing update manager from moonraker.conf" + file="${KLIPPER_CONFIG_HOME}/moonraker.conf" + if [ -f "${file}" ]; then + restart=0 + + update_section=$(grep -c '\[update_manager happy-hare\]' ${file} || true) + if [ "${update_section}" -eq 0 ]; then + echo -e "${INFO}[update_manager happy-hare] not found in moonraker.conf - skipping removal" + else + cat "${file}" | sed -e " \ + /\[update_manager happy-hare\]/,+6 d; \ + " > "${file}.new" && mv "${file}.new" "${file}" + restart=1 + fi + + update_section=$(grep -c '\[mmu_server\]' ${file} || true) + if [ "${update_section}" -eq 0 ]; then + echo -e "${INFO}[mmu_server] not found in moonraker.conf - skipping removal" + else + cat "${file}" | sed -e " \ + /\[mmu_server\]/,+1 d; \ + /enable_file_preprocessor/ d; \ + /enable_toolchange_next_pos/ d; \ + /update_spoolman_location/ d; \ + " > "${file}.new" && mv "${file}.new" "${file}" + restart=1 + fi + + if [ "$restart" -eq 1 ]; then + restart_moonraker + fi + else + echo -e "${WARNING}moonraker.conf not found!" + fi +} + +restart_klipper() { + if [ "$NOSERVICE" -ne 1 ]; then + echo -e "${INFO}Restarting Klipper..." + + if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then + set +e + /etc/init.d/*klipper_service restart + set -e + else + sudo systemctl restart ${KLIPPER_SERVICE} + fi + else + echo -e "${WARNING}Klipper restart suppressed - Please restart ${KLIPPER_SERVICE} by hand" + fi +} + +restart_moonraker() { + if [ "$NOSERVICE" -ne 1 ]; then + echo -e "${INFO}Restarting Moonraker..." + + if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then + set +e + /etc/init.d/*moonraker_service restart + set -e + else + sudo systemctl restart moonraker + fi + else + echo -e "${WARNING}Moonraker restart suppressed - Please restart by hand" + fi +} + +prompt_yn() { + while true; do + read -n1 -p "$@ (y/n)? " yn + case "${yn}" in + Y|y) + echo -n "y" + break + ;; + N|n) + echo -n "n" + break + ;; + *) + ;; + esac + done +} + +prompt_123() { + prompt=$1 + max=$2 + while true; do + if [ -z "${max}" ]; then + read -ep "${prompt}? " number + elif [[ "${max}" -lt 10 ]]; then + read -ep "${prompt} (1-${max})? " -n1 number + else + read -ep "${prompt} (1-${max})? " number + fi + if ! [[ "$number" =~ ^-?[0-9]+$ ]] ; then + echo -e "Invalid value." >&2 + continue + fi + if [ "$number" -lt 1 ]; then + echo -e "Value must be greater than 0." >&2 + continue + fi + if [ -n "$max" ] && [ "$number" -gt "$max" ]; then + echo -e "Value must be less than $((max+1))." >&2 + continue + fi + echo ${number} + break + done +} + +prompt_option() { + local var_name="$1" + local query="$2" + shift 2 + local i=0 + for val in "$@"; do + i=$((i+1)) + echo "$i) $val" + done + REPLY=$(prompt_123 "$query" "$#") + declare -g $var_name="${!REPLY}" +} + +option() { + local var_name="$1" + local desc="$2" + declare -g $var_name="${desc}" + OPTIONS+=("$desc") +} + +questionaire() { + + # Establish baseline hardware config placeholders to ensure all tokens are expanded + _hw_color_order="GRBW" + _hw_has_bypass=0 + + HAS_ESPOOLER=no + + echo + echo -e "${INFO}Let me see if I can get you started with initial configuration" + echo -e "You will still have some manual editing to perform but I will explain that later" + echo -e "(Note that all this script does is set a lot of the time consuming parameters in the config" + echo + echo -e "${PROMPT}${SECTION}What type of MMU are you running?${INPUT}" + OPTIONS=() + option ERCF11 'Enraged Rabbit Carrot Feeder v1.1' + option ERCF20 'ERCF v2.0' + option ERCF30 'ERCF v3.0' + option TRADRACK 'Tradrack v1.0' + option ANGRY_BEAVER 'Angry Beaver v1.0' + option BOX_TURTLE 'Box Turtle v1.0' + option NIGHT_OWL 'Night Owl v1.0' + #option HTLF 'Happy Turtle Lettuce Feeder' + option _3MS '3MS (Modular Multi Material System) v1.0' + option _3D_CHAMELEON '3D Chameleon' + option PICO_MMU 'PicoMMU' + option QUATTRO_BOX 'QuattroBox v1.0' + option QUATTRO_BOX11 'QuattroBox v1.1' + option MMX 'MMX' + option VVD 'BigTreeTech ViViD (BETA)' + option KMS 'KMS' + option OTHER 'Other / Custom (or just want starter config files)' + prompt_option opt 'MMU Type' "${OPTIONS[@]}" + case $opt in + "$ERCF11") + HAS_ENCODER=yes + HAS_SELECTOR=yes + HAS_SERVO=yes + _hw_mmu_vendor="ERCF" + _hw_mmu_version="1.1" + _hw_selector_type=LinearSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=0 + _hw_gear_gear_ratio="80:20" + _hw_gear_run_current=0.5 + _hw_gear_hold_current=0.1 + _hw_sel_gear_ratio="1:1" + _hw_sel_run_current=0.4 + _hw_sel_hold_current=0.2 + _hw_encoder_resolution=0.7059 + _param_extruder_homing_endstop="collision" + _param_gate_homing_endstop="encoder" + _param_gate_parking_distance=23 + _param_servo_buzz_gear_on_down=3 + _param_servo_duration=0.4 + _param_servo_always_active=0 + _param_servo_buzz_gear_on_down=1 + + echo + echo -e "${PROMPT}Some popular upgrade options for ERCF v1.1 can automatically be setup. Let me ask you about them...${INPUT}" + yn=$(prompt_yn "Are you using the 'Springy' sprung servo selector cart") + echo + case $yn in + y) + _hw_mmu_version+="s" + ;; + esac + yn=$(prompt_yn "Are you using the improved 'Binky' encoder") + echo + case $yn in + y) + _hw_mmu_version+="b" + ;; + esac + yn=$(prompt_yn "Are you using the wider 'Triple-Decky' filament blocks") + echo + case $yn in + y) + _hw_mmu_version+="t" + ;; + esac + ;; + + "$ERCF20") + HAS_ENCODER=yes + HAS_SELECTOR=yes + HAS_SERVO=yes + _hw_mmu_vendor="ERCF" + _hw_mmu_version="2.0" + _hw_selector_type=LinearSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=0 + _hw_gear_gear_ratio="80:20" + _hw_gear_run_current=0.5 + _hw_gear_hold_current=0.1 + _hw_sel_gear_ratio="1:1" + _hw_sel_run_current=0.4 + _hw_sel_hold_current=0.2 + _hw_encoder_resolution=1.0 + _param_extruder_homing_endstop="collision" + _param_gate_homing_endstop="encoder" + _param_gate_parking_distance=13 # ThumperBlocks is 11 + _param_servo_buzz_gear_on_down=3 + _param_servo_duration=0.4 + _param_servo_always_active=0 + _param_servo_buzz_gear_on_down=1 + ;; + + "$ERCF30") + HAS_ENCODER=yes + HAS_SELECTOR=yes + HAS_SERVO=yes + _hw_mmu_vendor="ERCF" + _hw_mmu_version="2.5" + _hw_selector_type=LinearSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=0 + _hw_gear_gear_ratio="1:1" + _hw_gear_run_current=0.8 + _hw_gear_hold_current=0.2 + _hw_sel_gear_ratio="1:1" + _hw_sel_run_current=0.7 + _hw_sel_hold_current=0.2 + _hw_encoder_resolution=1.0 + _param_extruder_homing_endstop="collision" + _param_gate_homing_endstop="encoder" + _param_gate_parking_distance=16 + _param_servo_buzz_gear_on_down=3 + _param_servo_duration=0.4 + _param_servo_always_active=0 + _param_servo_buzz_gear_on_down=1 + ;; + + "$TRADRACK") + HAS_ENCODER=no + HAS_SELECTOR=yes + HAS_SERVO=yes + _hw_mmu_vendor="Tradrack" + _hw_mmu_version="1.0" + _hw_selector_type=LinearSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=0 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=0 + _hw_gear_gear_ratio="50:17" + _hw_gear_run_current=1.27 + _hw_gear_hold_current=0.2 + _hw_sel_gear_ratio="1:1" + _hw_sel_run_current=0.63 + _hw_sel_hold_current=0.2 + _param_extruder_homing_endstop="none" + _param_gate_homing_endstop="mmu_gate" + _param_gate_parking_distance=17.5 + _param_servo_buzz_gear_on_down=0 + _param_servo_always_active=1 + + echo -e "${PROMPT}Some popular upgrade options for Tradrack v1.0 can automatically be setup. Let me ask you about them...${INPUT}" + yn=$(prompt_yn "Are you using the 'Binky' encoder modification") + echo + case $yn in + y) + HAS_ENCODER=yes + _hw_mmu_version+="e" + _param_extruder_homing_endstop="collision" + _param_gate_homing_endstop="encoder" + _param_gate_parking_distance=48.0 + _param_gate_endstop_to_encoder=31.0 + ;; + esac + ;; + + "$ANGRY_BEAVER") + HAS_ENCODER=no + HAS_SELECTOR=no + HAS_SERVO=no + _hw_mmu_vendor="AngryBeaver" + _hw_mmu_version="1.0" + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=0 + _hw_filament_always_gripped=1 + _hw_gear_gear_ratio="1:1" + _hw_gear_run_current=0.7 + _hw_gear_hold_current=0.1 + + _param_extruder_homing_endstop="extruder" + _param_gate_homing_endstop="extruder" + _param_gate_homing_max=500 + _param_gate_preload_homing_max=500 + _param_gate_parking_distance=50 + _param_gear_homing_speed=80 + _param_has_filament_buffer=0 + ;; + + "$BOX_TURTLE") + HAS_ENCODER=no + HAS_SELECTOR=no + HAS_SERVO=no + HAS_ESPOOLER=yes + _hw_mmu_vendor="BoxTurtle" + _hw_mmu_version="1.0" + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=1 + _hw_gear_gear_ratio="50:10" + _hw_gear_run_current=0.7 + _hw_gear_hold_current=0.1 + + _param_extruder_homing_endstop="none" + _param_gate_homing_endstop="mmu_gate" + _param_gate_homing_max=300 + _param_gate_preload_homing_max=200 + _param_gate_parking_distance=100 + _param_gate_final_eject_distance=100 + _param_has_filament_buffer=0 + + _param_autocal_bowden_length=1 + _param_autotune_bowden_length=0 + _param_skip_cal_rotation_distance=0 + _param_autotune_rotation_distance=1 + _param_skip_cal_encoder=0 + _param_autotune_encoder=0 + + _param_sync_feedback_enabled=1 + _param_sync_feedback_buffer_range=8 + _param_sync_feedback_buffer_maxrange=12 + ;; + + "$NIGHT_OWL") + HAS_ENCODER=no + HAS_SELECTOR=no + HAS_SERVO=no + _hw_mmu_vendor="NightOwl" + _hw_mmu_version="1.0" + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=1 + _hw_gear_gear_ratio="50:10" + _hw_gear_run_current=0.7 + _hw_gear_hold_current=0.1 + + _param_extruder_homing_endstop="none" + _param_gate_homing_endstop="mmu_gear" + _param_gate_homing_max=100 + _param_gate_homing_buffer=50 + _param_gate_parking_distance=0 + _param_gate_final_eject_distance=100 + _param_has_filament_buffer=0 + ;; + + "$HTLF") + # Comming soon (j/k)... + HAS_ENCODER=no + HAS_SELECTOR=no + HAS_SERVO=yes + ;; + + "$_3MS") + HAS_ENCODER=no + HAS_SELECTOR=no + HAS_SERVO=no + HELP_URL="https://github.com/moggieuk/Happy-Hare/wiki/Quick-Start-3MS" + HELP_URL_B="https://3dcoded.github.io/3MS/instructions/" + + _hw_mmu_vendor="3MS" + _hw_mmu_version="1.0" + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=0 + _hw_filament_always_gripped=1 + _hw_gear_gear_ratio="1:1" + _hw_gear_run_current=0.7 + _hw_gear_hold_current=0.1 + + _param_extruder_homing_endstop="extruder" + _param_gate_homing_endstop="extruder" + _param_gate_homing_max=500 + _param_gate_parking_distance=250 + _param_gear_homing_speed=80 + ;; + + "$_3D_CHAMELEON") + HAS_ENCODER=no + HAS_SELECTOR=yes + HAS_SERVO=no + SETUP_SELECTOR_TOUCH=no + + _hw_mmu_vendor="3DChameleon" + _hw_mmu_version="1.0" + _hw_selector_type=RotarySelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=0 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=0 + _hw_gear_gear_ratio="1:1" + _hw_gear_run_current=0.7 + _hw_gear_hold_current=0.1 + _hw_sel_gear_ratio="1:1" + _hw_sel_run_current=0.63 + _hw_sel_hold_current=0.2 + + _param_extruder_homing_endstop="none" + _param_gate_homing_endstop="mmu_gate" + _param_gate_homing_max=500 + _param_gate_parking_distance=250 + _param_gear_homing_speed=80 + ;; + + "$PICO_MMU") + HAS_ENCODER=no + HAS_SELECTOR=no + HAS_SERVO=yes + SETUP_SELECTOR_TOUCH=no + + _hw_mmu_vendor="PicoMMU" + _hw_mmu_version="1.0" + _hw_selector_type=ServoSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=0 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=0 + _hw_gear_gear_ratio="1.25:1" + _hw_gear_run_current=0.7 + _hw_gear_hold_current=0.1 + _hw_chain_count=4 + _hw_exit_leds="neopixel:mmu_leds (1-4)" + _hw_entry_leds="" + _hw_status_leds="" + _hw_logo_leds="" + + _param_extruder_homing_endstop="none" + _param_gate_homing_endstop="mmu_gate" + _param_gate_homing_max=100 + _param_gate_parking_distance=25 + _param_gear_homing_speed=80 + ;; + + "$QUATTRO_BOX") + HAS_ENCODER=yes + HAS_SELECTOR=no + HAS_SERVO=no + ADDONS_EJECT_BUTTONS=1 + + # mmu_hardware config + _hw_mmu_vendor="QuattroBox" + _hw_mmu_version="1.0" + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=1 + _hw_gear_gear_ratio="50:17" + _hw_gear_run_current=1.27 + _hw_gear_hold_current=0.2 + _hw_chain_count=32 + _hw_color_order="GRBW,GRBW,GRBW,GRBW,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB" + _hw_exit_leds="neopixel:mmu_leds (1-4)" + _hw_status_leds="neopixel:mmu_leds (5-14)" + _hw_logo_leds="neopixel:mmu_leds (15-32)" + + # mmu_parameters config + _param_extruder_homing_endstop="collision" + _param_gate_homing_endstop="mmu_gate" + _param_gate_homing_max=200 + _param_gate_preload_homing_max=200 + _param_gate_unload_buffer=50 + _param_gate_parking_distance=30 + _param_gate_endstop_to_encoder=18 + _param_gate_autoload=1 + _param_gate_final_eject_distance=200 + _param_has_filament_buffer=0 + + # mmu_macro_vars config + variable_default_status_effect='1, 0.15, 0.66' + variable_default_logo_effect='1, 0.15, 0.66' + ;; + + "$QUATTRO_BOX11") + HAS_ENCODER=yes + HAS_SELECTOR=no + HAS_SERVO=no + ADDONS_EJECT_BUTTONS=1 + + # mmu_hardware config + _hw_mmu_vendor="QuattroBox" + _hw_mmu_version="1.0" + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=1 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=1 + _hw_gear_gear_ratio="50:17" + _hw_gear_run_current=1.27 + _hw_gear_hold_current=0.2 + _hw_chain_count=36 + _hw_color_order="GRBW,GRBW,GRBW,GRBW,GRBW,GRBW,GRBW,GRBW,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB" + _hw_exit_leds="neopixel:mmu_leds (2,4,6,8)" + _hw_entry_leds="neopixel:mmu_leds (1,3,5,7)" + _hw_status_leds="neopixel:mmu_leds (9-36)" + + # mmu_parameters config + _param_extruder_homing_endstop="collision" + _param_gate_homing_endstop="mmu_gate" + _param_gate_homing_max=200 + _param_gate_preload_homing_max=200 + _param_gate_unload_buffer=50 + _param_gate_parking_distance=30 + _param_gate_endstop_to_encoder=18 + _param_gate_autoload=1 + _param_gate_final_eject_distance=200 + _param_has_filament_buffer=0 + + # mmu_macro_vars config + variable_default_exit_effect="gate_status_exit" + variable_default_entry_effect="gate_status" + variable_default_status_effect="filament_color" + ;; + + "$MMX") + HAS_ENCODER=no + HAS_SELECTOR=no + HAS_SERVO=yes + SETUP_SELECTOR_TOUCH=no + + _hw_mmu_vendor="MMX" + _hw_mmu_version="1.0" + _hw_selector_type=ServoSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=0 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=1 + _hw_gear_gear_ratio="80:20" + _hw_gear_run_current=0.7 + _hw_gear_hold_current=0.1 + _hw_chain_count=4 + _hw_exit_leds="neopixel:mmu_leds (4-1)" + _hw_entry_leds="" + _hw_status_leds="" + _hw_logo_leds="" + + _param_extruder_homing_endstop="none" + _param_gate_homing_endstop="mmu_gate" + _param_gate_homing_max=1000 + _param_gate_preload_homing_max=1000 + _param_gate_parking_distance=25 + _param_gear_homing_speed=80 + _param_selector_gate_angles="60,0,180,120" + ;; + + "$VVD") + # Comming soon (Bigtreetech)... + HAS_ENCODER=no + HAS_SELECTOR=yes + HAS_SERVO=no + HAS_ESPOOLER=yes + SETUP_LED=yes + # Note VVD has preconfigured mmu_hardware.cfg based on dedicated electronics + _hw_num_gates=4 + _hw_mmu_vendor="VVD" + _hw_mmu_version="1.0" + _hw_selector_type=IndexedSelector + + # mmu_parameters config + _param_extruder_homing_endstop="filament_compression" + _param_extruder_homing_max=250 + _param_extruder_homing_buffer=80 + _param_gate_homing_endstop="mmu_gear" + _param_gate_homing_max=250 + _param_gate_unload_buffer=80 + _param_gate_parking_distance=30 + _param_gate_preload_homing_max=750 + _param_gate_preload_parking_distance=30 + _param_gate_final_eject_distance=750 + _param_gate_autoload=1 + _param_has_filament_buffer=0 + + _param_autocal_bowden_length=1 + _param_autotune_bowden_length=0 + _param_skip_cal_rotation_distance=1 + _param_autotune_rotation_distance=1 + + _param_sync_feedback_enabled=1 + _param_sync_feedback_buffer_range=8 + _param_sync_feedback_buffer_maxrange=12 + + _param_update_aht10_commands=1 + ;; + + "$KMS") + HAS_ENCODER=yes + HAS_SELECTOR=no + HAS_SERVO=no + HAS_ESPOOLER=yes + SETUP_LED=yes + # Note KMS has preconfigured mmu_hardware.cfg based on dedicated electronics + _hw_num_gates=4 + _hw_mmu_vendor="KMS" + _hw_mmu_version="1.0" + _hw_selector_type=VirtualSelector + + # mmu_parameters config + _param_extruder_homing_endstop="filament_compression" + _param_gate_homing_endstop="mmu_gate" + _param_gate_homing_max=300 + _param_gate_preload_homing_max=300 + _param_gate_preload_parking_distance=-10 + _param_gate_parking_distance=20 + _param_gate_unload_buffer=50 + _param_gate_endstop_to_encoder=14 + _param_gate_autoload=1 + _param_gate_final_eject_distance=300 + _param_has_filament_buffer=0 + + _param_autocal_bowden_length=1 + _param_autotune_bowden_length=0 + _param_skip_cal_rotation_distance=0 + _param_autotune_rotation_distance=1 + _param_skip_cal_encoder=0 + _param_autotune_encoder=0 + + _param_sync_feedback_enabled=1 + _param_sync_feedback_buffer_range=8 + _param_sync_feedback_buffer_maxrange=12 + ;; + + *) + HAS_ENCODER=yes + HAS_SELECTOR=yes + HAS_SERVO=yes + HAS_ESPOOLER=yes + SETUP_LED=yes + SETUP_SELECTOR_TOUCH=no + + _hw_mmu_vendor="Other" + _hw_mmu_version="1.0" + _hw_selector_type=LinearSelector + _hw_variable_bowden_lengths=0 + _hw_variable_rotation_distances=0 + _hw_require_bowden_move=1 + _hw_filament_always_gripped=0 + _hw_gear_gear_ratio="1:1" + _hw_gear_run_current=0.7 + _hw_gear_hold_current=0.1 + _hw_sel_gear_ratio="1:1" + _hw_sel_run_current=0.5 + _hw_sel_hold_current=0.1 + + # This isn't meant to be all-inclusive of options. It is just to provide a config starting point that is close + echo -e "${PROMPT}${SECTION}Which of these most closely resembles your MMU design (this allows for some tuning of config files)?{$INPUT}" + OPTIONS=() # reset option array + option TYPE_A_WITH_ENCODER 'Type-A (selector) with Encoder' + option TYPE_A_NO_ENCODER 'Type-A (selector), No Encoder' + option TYPE_A_NO_ENCODER_NO_SERVO_NO_ESPOOLER 'Type-A (selector), No Encoder, No Servo, No ESpooler' + option TYPE_B_WITH_ENCODER 'Type-B (mutliple filament drive steppers) with Encoder' + option TYPE_B_WITH_SHARED_GATE_AND_ENCODER 'Type-B (multiple filament drive steppers) with shared Gate sensor and Encoder' + option TYPE_B_WITH_SHARED_GATE_NO_ENCODER 'Type-B (multiple filament drive steppers) with shared Gate sensor, No Encoder' + option TYPE_B_WITH_INDIVIDUAL_GEAR_SENSOR_AND_ENCODER 'Type-B (multiple filament drive steppers) with individual post-gear sensors and Encoder' + option TYPE_B_WITH_INDIVIDUAL_GEAR_SENSOR_NO_ENCODER 'Type-B (multiple filament drive steppers) with individual post-gear sensors, No Encoder' + option OTHER 'Just turn on all options and let me configure' + prompt_option opt 'Type' "${OPTIONS[@]}" + case "$opt" in + "$TYPE_A_WITH_ENCODER") + _param_gate_homing_endstop="encoder" + _param_extruder_homing_endstop="collision" + echo + echo -e "${WARNING} IMPORTANT: Since you have a custom MMU with selector you will need to setup some CAD dimensions in mmu_parameters.cfg... See doc" + ;; + "$TYPE_A_NO_ENCODER") + HAS_ENCODER=no + _param_gate_homing_endstop="mmu_gate" + _param_extruder_homing_endstop="none" + echo + echo -e "${WARNING} IMPORTANT: Since you have a custom MMU with selector you will need to setup some CAD dimensions in mmu_parameters.cfg... See doc" + ;; + "$TYPE_A_NO_ENCODER_NO_SERVO_NO_ESPOOLER") + HAS_ENCODER=no + HAS_SERVO=no + HAS_ESPOOLER=no + _param_gate_homing_endstop="mmu_gate" + _param_extruder_homing_endstop="none" + echo + echo -e "${WARNING} IMPORTANT: Since you have a custom MMU with selector you will need to setup some CAD dimensions in mmu_parameters.cfg... See doc" + ;; + "$TYPE_B_WITH_ENCODER") + HAS_SELECTOR=no + HAS_SERVO=no + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=1 + _hw_variable_rotation_distances=1 + _hw_filament_always_gripped=1 + _param_gate_homing_endstop="mmu_gate" + _param_extruder_homing_endstop="none" + ;; + "$TYPE_B_WITH_SHARED_GATE_AND_ENCODER") + HAS_SELECTOR=no + HAS_SERVO=no + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=1 + _hw_variable_rotation_distances=1 + _hw_filament_always_gripped=1 + _param_gate_homing_endstop="mmu_gate" + _param_extruder_homing_endstop="none" + ;; + "$TYPE_B_WITH_SHARED_GATE_NO_ENCODER") + HAS_SELECTOR=no + HAS_SERVO=no + HAS_ENCODER=no + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=1 + _hw_variable_rotation_distances=1 + _hw_filament_always_gripped=1 + _param_gate_homing_endstop="mmu_gate" + _param_extruder_homing_endstop="none" + ;; + "$TYPE_B_WITH_INDIVIDUAL_GEAR_SENSOR_AND_ENCODER") + HAS_SELECTOR=no + HAS_SERVO=no + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=1 + _hw_variable_rotation_distances=1 + _hw_filament_always_gripped=1 + _param_gate_homing_endstop="mmu_gear" + _param_extruder_homing_endstop="none" + ;; + "$TYPE_B_WITH_INDIVIDUAL_GEAR_SENSOR_NO_ENCODER") + HAS_SELECTOR=no + HAS_SERVO=no + HAS_ENCODER=no + HAS_ESPOOLER=yes + _hw_selector_type=VirtualSelector + _hw_variable_bowden_lengths=1 + _hw_variable_rotation_distances=1 + _hw_filament_always_gripped=1 + _param_gate_homing_endstop="mmu_gear" + _param_extruder_homing_endstop="none" + ;; + *) + _param_gate_homing_endstop="mmu_gate" + _param_extruder_homing_endstop="none" + ;; + esac + ;; + esac + + if [ "${_hw_mmu_vendor}" != "KMS" -a "${_hw_mmu_vendor}" != "VVD" ]; then + echo -e "${PROMPT}${SECTION}How many gates (lanes) do you have?${INPUT}" + _hw_num_gates=$(prompt_123 "Number of gates") + fi + + if [ "${_hw_mmu_vendor}" == "KMS" ]; then + pattern="Klipper_stm32" + for line in `ls /dev/serial/by-id 2>/dev/null | grep -E "Klipper_"`; do + if echo ${line} | grep -q "${pattern}"; then + echo -e "${PROMPT}${SECTION}Is '/dev/serial/by-id/${line}' a ${EMPHASIZE}KMS${PROMPT} controller serial port?${INPUT}" + OPTIONS=() + option KMS 'KMS MMU' + option BUFFER 'KMS Buffer (sync-feedback sensor)' + option NEITHER 'No, not related to KMS' + prompt_option opt 'KMS MCU?' "${OPTIONS[@]}" + case $opt in + "$KMS") + _hw_serial1="/dev/serial/by-id/${line}" + ;; + "$BUFFER") + _hw_serial2="/dev/serial/by-id/${line}" + ;; + *) + ;; + esac + fi + done + if [ "${_hw_serial1}" == "" ]; then + echo + echo -e "${WARNING} Couldn't find your MMU serial port, but no worries - I'll configure the default and you can manually change later" + _hw_serial1='/dev/ttyACM1 # Config guess. Run ls -l /dev/serial/by-id and set manually' + fi + if [ "${_hw_serial2}" == "" ]; then + echo + echo -e "${WARNING} Couldn't find your Bufffer (sync-feedback sensor) serial port, but no worries - I'll configure the default and you can manually change later" + _hw_serial1='/dev/ttyACM2 # Config guess. Run ls -l /dev/serial/by-id and set manually' + fi + + elif [ "${_hw_mmu_vendor}" == "VVD" ]; then + pattern="Klipper_stm32" + for line in `ls /dev/serial/by-id 2>/dev/null | grep -E "Klipper_"`; do + if echo ${line} | grep -q "${pattern}"; then + echo -e "${PROMPT}${SECTION}Is '/dev/serial/by-id/${line}' a ${EMPHASIZE}KMS${PROMPT} controller serial port?${INPUT}" + OPTIONS=() + option VVD 'ViVid MMU' + option BUFFER 'ViViD Buffer (sync-feedback sensor)' + option NEITHER 'No, not related to ViViD' + prompt_option opt 'ViViD MCU?' "${OPTIONS[@]}" + case $opt in + "$VVD") + _hw_serial1="/dev/serial/by-id/${line}" + ;; + "$BUFFER") + _hw_serial2="/dev/serial/by-id/${line}" + ;; + *) + ;; + esac + fi + done + if [ "${_hw_serial1}" == "" ]; then + echo + echo -e "${WARNING} Couldn't find your MMU serial port, but no worries - I'll configure the default and you can manually change later" + _hw_serial1='/dev/ttyACM1 # Config guess. Run ls -l /dev/serial/by-id and set manually' + fi + if [ "${_hw_serial2}" == "" ]; then + echo + echo -e "${WARNING} Couldn't find your Bufffer (sync-feedback sensor) serial port, but no worries - I'll configure the default and you can manually change later" + _hw_serial1='/dev/ttyACM2 # Config guess. Run ls -l /dev/serial/by-id and set manually' + fi + + else + _hw_brd_type="unknown" + echo -e "${PROMPT}${SECTION}Select mcu board type used to control MMU${INPUT}" + # Perhaps consider just supporting the BTT MMB (and eventually AFC) when mmu_vendor is BoxTurtle + # as many of these other boards may not work (due lack of exposed gpio) + OPTIONS=() + option MMB10 'BTT MMB v1.0 (with CANbus)' + option MMB11 'BTT MMB v1.1 (with CANbus)' + option MMB20 'BTT MMB v2.0 (with CANbus)' + option FYSETC_BURROWS_ERB_1 'Fysetc Burrows ERB v1' + option FYSETC_BURROWS_ERB_2 'Fysetc Burrows ERB v2' + option EASY_BRD_SAMD21 'Standard EASY-BRD (with SAMD21)' + option EASY_BRD_RP2040 'EASY-BRD with RP2040' + option MELLOW_BRD_1 'Mellow EASY-BRD v1.x (with CANbus)' + option MELLOW_BRD_2 'Mellow EASY-BRD v2.x (with CANbus)' + option TZB_1 'TZB v1.0' + option AFC_LITE_1 'AFC Lite v1.0' + option WGB_3 'WGB v3.0' + option SKR_PICO_1 'BTT SKR Pico v1.0' + option EBB42_12 'BTT EBB 42 CANbus v1.2 (for MMX or Pico)' + option OTHER 'Not in list / Unknown' + prompt_option opt 'MCU Type' "${OPTIONS[@]}" + case $opt in + "$MMB10") + _hw_brd_type="MMB10" + pattern="Klipper_stm32" + ;; + "$MMB11") + _hw_brd_type="MMB11" + pattern="Klipper_stm32" + ;; + "$MMB20") + _hw_brd_type="MMB20" + pattern="Klipper_stm32" + ;; + "$FYSETC_BURROWS_ERB_1") + _hw_brd_type="ERB" + pattern="Klipper_rp2040" + ;; + "$FYSETC_BURROWS_ERB_2") + _hw_brd_type="ERBv2" + pattern="Klipper_rp2040" + ;; + "$EASY_BRD_SAMD21") + _hw_brd_type="EASY-BRD" + pattern="Klipper_samd21" + ;; + "$EASY_BRD_RP2040") + _hw_brd_type="EASY-BRD-RP2040" + pattern="Klipper_rp2040" + ;; + "$MELLOW_BRD_1") + _hw_brd_type="MELLOW-EASY-BRD-CAN" + pattern="Klipper_rp2040" + ;; + "$MELLOW_BRD_2") + _hw_brd_type="MELLOW-EASY-BRD-CANv2" + pattern="Klipper_rp2040" + ;; + "$TZB_1") + _hw_brd_type="TZB_1" + pattern="Klipper_stm32" + ;; + "$AFC_LITE_1") + _hw_brd_type="AFC_LITE_1" + pattern="Klipper_stm32" + ;; + "$WGB_3") + _hw_brd_type="WGB_3" + pattern="Klipper_stm32" + ;; + "$SKR_PICO_1") + _hw_brd_type="SKR_PICO_1" + pattern="Klipper_rp2040" + ;; + "$EBB42_12") + _hw_brd_type="EBB42_12" + pattern="Klipper_" + ;; + *) + _hw_brd_type="unknown" + pattern="Klipper_" + ;; + esac + + for line in `ls /dev/serial/by-id 2>/dev/null | grep -E "Klipper_"`; do + if echo ${line} | grep -q "${pattern}"; then + echo -e "${PROMPT}${SECTION}This looks like your ${EMPHASIZE}${_hw_brd_type}${PROMPT} controller serial port. Is that correct?${INPUT}" + yn=$(prompt_yn "/dev/serial/by-id/${line}") + echo + case $yn in + y) + _hw_serial="/dev/serial/by-id/${line}" + break + ;; + n) + ;; + esac + fi + done + if [ "${_hw_serial}" == "" ]; then + echo + echo -e "${WARNING} Couldn't find your serial port, but no worries - I'll configure the default and you can manually change later" + _hw_serial='/dev/ttyACM1 # Config guess. Run ls -l /dev/serial/by-id and set manually' + fi + + # Avoid pin duplication. Most type-A MMU's have either encoder or gate. If both, user will have to fix + if [ "${HAS_ENCODER}" == "yes" ]; then + eval PIN[${_hw_brd_type},gate_sensor_pin]="" + else + eval PIN[${_hw_brd_type},encoder_pin]="" + fi + + echo -e "${PROMPT}${SECTION}Would you like to have neopixel LEDs setup now for your MMU?${INPUT}" + yn=$(prompt_yn "Enable LED support?") + echo + case $yn in + y) + SETUP_LED=yes + ;; + n) + SETUP_LED=no + ;; + esac + + if [ "${HAS_SELECTOR}" == "yes" ]; then + + if [ "$SETUP_SELECTOR_TOUCH" != "no" ]; then + echo -e "${PROMPT}${SECTION}Touch selector operation using TMC Stallguard? This allows for additional selector recovery steps but is difficult to tune" + echo -e "Not recommend if you are new to MMU/Happy Hare & MCU must have DIAG output for selector stepper. Can configure later${INPUT}" + yn=$(prompt_yn "Enable selector touch operation") + echo + case $yn in + y) + if [ "${_hw_brd_type}" == "EASY-BRD" ]; then + echo + echo -e "${WARNING} IMPORTANT: Set the J6 jumper pins to 2-3 and 4-5, i.e. .[..][..] MAKE A NOTE NOW!!" + fi + SETUP_SELECTOR_TOUCH=yes + ;; + n) + if [ "${_hw_brd_type}" == "EASY-BRD" ]; then + echo + echo -e "${WARNING} IMPORTANT: Set the J6 jumper pins to 1-2 and 4-5, i.e. [..].[..] MAKE A NOTE NOW!!" + fi + SETUP_SELECTOR_TOUCH=no + ;; + esac + fi + + if [ "$SETUP_SELECTOR_TOUCH" == "no" ]; then + echo -e "${PROMPT}${SECTION}Selector homing using TMC Stallguard? This prevents the need for hard endstop homing but must be tuned" + echo -e "MCU must have DIAG output for selector stepper. Can configure later${INPUT}" + yn=$(prompt_yn "Enable selector stallguard homing") + echo + case $yn in + y) + SETUP_SELECTOR_STALLGUARD_HOMING=yes + ;; + n) + SETUP_SELECTOR_STALLGUARD_HOMING=no + ;; + esac + fi + fi + + if [ "${HAS_SERVO}" == "yes" ]; then + + if [ "${_hw_mmu_vendor}" == "ERCF" ]; then + echo -e "${PROMPT}${SECTION}Which servo are you using?${INPUT}" + OPTIONS=() + option MG90S 'MG-90S' + option SH0255MG 'Savox SH0255MG' + option DS041MG 'GDW DS041MG' + option OTHER 'Not listed / Other' + prompt_option opt 'Servo' "${OPTIONS[@]}" + case $opt in + "$MG90S") + _hw_maximum_servo_angle=180 + _hw_minimum_pulse_width=0.00085 + _hw_maximum_pulse_width=0.00215 + _param_servo_always_active=0 + _param_servo_up_angle=30 + if [ "${_hw_mmu_version}" == "2.0" ]; then + _param_servo_move_angle=61 + else + _param_servo_move_angle=${_param_servo_up_angle} + fi + _param_servo_down_angle=140 + ;; + "$SH0255MG") + _hw_maximum_servo_angle=180 + _hw_minimum_pulse_width=0.00085 + _hw_maximum_pulse_width=0.00215 + _param_servo_always_active=0 + _param_servo_up_angle=140 + if [ "${_hw_mmu_version}" == "2.0" ]; then + _param_servo_move_angle=109 + else + _param_servo_move_angle=${_param_servo_up_angle} + fi + _param_servo_down_angle=30 + ;; + "$DS041MG") + _hw_maximum_servo_angle=180 + _hw_minimum_pulse_width=0.00050 + _hw_maximum_pulse_width=0.00250 + _param_servo_always_active=1 + _param_servo_up_angle=30 + if [ "${_hw_mmu_version}" == "2.0" ]; then + _param_servo_move_angle=50 + else + _param_servo_move_angle=${servo_up_angle} + fi + _param_servo_down_angle=100 + ;; + *) + _hw_maximum_servo_angle=180 + _hw_minimum_pulse_width=0.00085 + _hw_maximum_pulse_width=0.00215 + _param_servo_always_active=0 + ;; + esac + + elif [ "${_hw_mmu_vendor}" == "Tradrack" ]; then + echo -e "${PROMPT}${SECTION}Which servo are you using?${INPUT}" + OPTIONS=() + option TRADRACK_BOM 'PS-1171MG or FT1117M (Tradrack)' + option OTHER 'Not listed / Other' + prompt_option opt 'Servo' "${OPTIONS[@]}" + case $opt in + "$TRADRACK_BOM") + _hw_maximum_servo_angle=131 + _hw_minimum_pulse_width=0.00070 + _hw_maximum_pulse_width=0.00220 + _param_servo_always_active=1 + _param_servo_up_angle=145 + _param_servo_move_angle=${servo_up_angle} + _param_servo_down_angle=1 + ;; + *) + _hw_maximum_servo_angle=131 + _hw_minimum_pulse_width=0.00070 + _hw_maximum_pulse_width=0.00230 + _param_servo_always_active=1 + _param_servo_up_angle=145 + _param_servo_move_angle=${servo_up_angle} + _param_servo_down_angle=1 + ;; + esac + + elif [ "${_hw_mmu_vendor}" == "PicoMMU" -o "${_hw_mmu_vendor}" == "MMX" ]; then + echo -e "${PROMPT}${SECTION}Which servo are you using?${INPUT}" + OPTIONS=() + option MMX_BOM 'MG996R' + option EMAX_ES3004 'EMAX ES3004' + option OTHER 'Not listed / Other' + prompt_option opt 'Servo' "${OPTIONS[@]}" + case $opt in + "$MMX_BOM") + _hw_maximum_servo_angle=180 + _hw_minimum_pulse_width=0.00070 + _hw_maximum_pulse_width=0.00230 + _param_servo_always_active=0 + _param_servo_duration=0.6 + _param_servo_dwell=1.0 + ;; + "$EMAX_ES3004") + _hw_maximum_servo_angle=140 + _hw_minimum_pulse_width=0.00070 + _hw_maximum_pulse_width=0.00230 + _param_servo_always_active=0 + _param_servo_duration=0.6 + _param_servo_dwell=1.2 + ;; + *) + _hw_maximum_servo_angle=180 + _hw_minimum_pulse_width=0.001 + _hw_maximum_pulse_width=0.002 + _param_servo_always_active=1 + _param_servo_duration=0.6 + _param_servo_dwell=1.0 + ;; + esac + + else + # Other (unknown) vendor + _hw_maximum_servo_angle=180 + _hw_minimum_pulse_width=0.001 + _hw_maximum_pulse_width=0.002 + _param_servo_always_active=0 + _param_servo_up_angle=0 + _param_servo_move_angle=0 + _param_servo_down_angle=0 + fi + fi + fi + + if [ "${HAS_ENCODER}" == "yes" ]; then + echo -e "${PROMPT}${SECTION}Clog detection? This uses the MMU encoder movement to detect clogs and can call your filament runout logic${INPUT}" + yn=$(prompt_yn "Enable clog detection") + echo + case $yn in + y) + _param_enable_clog_detection=1 + echo -e "${PROMPT} Would you like MMU to automatically adjust clog detection length (recommended)?${INPUT}" + yn=$(prompt_yn " Automatic") + echo + if [ "${yn}" == "y" ]; then + _param_enable_clog_detection=2 + fi + ;; + n) + _param_enable_clog_detection=0 + ;; + esac + else + _param_enable_clog_detection=0 + fi + + echo -e "${PROMPT}${SECTION}EndlessSpool? This uses filament runout detection to automate switching to new spool without interruption${INPUT}" + yn=$(prompt_yn "Enable EndlessSpool") + echo + case $yn in + y) + _param_enable_endless_spool=1 + ;; + n) + _param_enable_endless_spool=0 + ;; + esac + + echo -e "${PROMPT}${SECTION}Finally, would you like me to include all the MMU config files into your ${PRINTER_CONFIG} file${INPUT}" + yn=$(prompt_yn "Add include?") + echo + case $yn in + y) + INSTALL_PRINTER_INCLUDES=yes + echo -e "${PROMPT} Would you like to include Mini 12864 screen menu configuration extension for MMU (only if you have one!)${INPUT}" + yn=$(prompt_yn " Include menu") + echo + case $yn in + y) + MENU_12864=1 + ;; + n) + MENU_12864=0 + ;; + esac + + echo -e "${PROMPT} Recommended: Would you like to include the default pause/resume macros supplied with Happy Hare${INPUT}" + yn=$(prompt_yn " Include client_macros.cfg") + echo + case $yn in + y) + CLIENT_MACROS=1 + ;; + n) + CLIENT_MACROS=0 + ;; + esac + + echo -e "${PROMPT} Addons: Would you like to include the EREC filament cutter macro (requires EREC servo installation)${INPUT}" + yn=$(prompt_yn " Include mmu_erec_cutter.cfg") + echo + case $yn in + y) + ADDONS_EREC=1 + ;; + n) + ADDONS_EREC=0 + ;; + esac + + echo -e "${PROMPT} Addons: Would you like to include the Blobifier purge system (requires Blobifier servo installation)${INPUT}" + yn=$(prompt_yn " Include blobifier.cfg") + echo + case $yn in + y) + ADDONS_BLOBIFIER=1 + ;; + n) + ADDONS_BLOBIFIER=0 + ;; + esac + ;; + n) + INSTALL_PRINTER_INCLUDES=no + ;; + esac + +# Too verbose.. +# echo -e "${EMPHASIZE}" +# echo -e "Summary of hardware config set by questionaire:${INFO}" +# for var in $(set | grep '^_hw_' | cut -d '=' -f 1); do +# short_name=$(echo "$var" | sed 's/^_hw_//') +# eval "echo -e \"$short_name: \${$var}\"" +# done + + echo -e "${INFO}" + echo " vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv" + echo + echo " NOTES:" + echo " What still needs to be done:" + echo " Edit mmu.cfg and mmu_hardware.cfg to get hardware correctly setup" + if [ "${_hw_brd_type}" == "unknown" ]; then + echo " * Edit *.cfg files and substitute all missing pins" + else + echo " * Review all pin configuration and change to match your mcu" + fi + echo " * Verify motor current, especially if using non BOM motors" + echo " * Adjust motor direction with '!' on pin if necessary. No way to know here" + echo " * Adjust your config for loading and unloading preferences" + echo -e "${WARNING}" + echo " Make sure you that you have these near the top of your printer.cfg:" + echo " # Happy Hare" + echo " [include mmu/base/*.cfg]" + echo " [include mmu/optional/client_macros.cfg]" + echo " [include mmu/addons/blobifier.cfg]" + echo " [include mmu/addons/mmu_erec_cutter.cfg]" + if [ "${ADDONS_EJECT_BUTTONS:-0}" -eq 1 ]; then + echo " [include mmu/addons/mmu_eject_buttons.cfg]" + fi + echo -e "${INFO}" + echo " Later:" + echo " * Tweak configurations like speed and distance in mmu_parameters.cfg" + echo " * Configure your operational preferences in mmu_macro_vars.cfg" + echo + echo " Good luck! MMU is complex to setup. Remember Discord is your friend.." + echo -e " Join the dedicated Happy Hare forum here: ${EMPHASIZE}https://discord.gg/98TYYUf6f2${INFO}" + if [ -n "${HELP_URL}" ]; then + echo -e " Make sure to follow these instructions while setting up your MMU:" + echo -e " ${EMPHASIZE}${HELP_URL}" + if [ -n "${HELP_URL_B}" ]; then + echo -e " ${EMPHASIZE}${HELP_URL_B}" + fi + fi + echo + echo " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^" + echo +} + +usage() { + echo -e "${EMPHASIZE}" + echo "Usage: $0 [-i] [-e] [-d] [-z] [-s]" + echo " [-b ] [-k ] [-c ] [-m ]" + echo " [-a ] [-r ]" + echo + echo "-i for interactive install" + echo "-e for install of default starter config files for manual configuration" + echo "-d for uninstall" + echo "-z skip github update check (nullifies -b )" + echo "-s to skip restart of services" + echo "-b to switch to specified feature branch (sticky)" + echo "-k to specify location of non-default klipper home directory" + echo "-c to specify location of non-default klipper config directory" + echo "-m to specify location of non-default moonraker home directory" + echo "-r specify Repetier-Server to override printer.cfg and klipper.service names" + echo "-a to specify alternative klipper-service-name when installed with Kiauh" + echo "(no flags for safe re-install / upgrade)" + echo + exit 1 +} + +# Find SRCDIR from the pathname of this script +SRCDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/ && pwd )" + +# Defaults for first time run without running interview/questionaire +_hw_brd_type="unknown" +_hw_serial="/dev/serial/by-id/XXX" +_hw_num_gates=12 +_hw_mmu_vendor=Custom +_hw_mmu_version=1.0 +_hw_selector_type=LinearSelector +_hw_variable_bowden_lengths=1 +_hw_variable_rotation_distances=1 +_hw_encoder_resolution=1.0 +SETUP_SELECTOR_TOUCH=no +SETUP_LED=yes +HAS_ENCODER=yes +HAS_SELECTOR=yes +ADDONS_EREC=0 +ADDONS_BLOBIFIER=0 +ADDONS_EJECT_BUTTONS=0 + +INSTALL=0 +UNINSTALL=0 +NOSERVICE=0 +STARTER=0 +PRINTER_CONFIG=printer.cfg +KLIPPER_SERVICE=klipper.service + +while getopts "a:b:k:c:m:r:idsze" arg; do + case $arg in + a) KLIPPER_SERVICE=${OPTARG}.service;; + b) N_BRANCH=${OPTARG};; + k) KLIPPER_HOME=${OPTARG};; + m) MOONRAKER_HOME=${OPTARG};; + c) KLIPPER_CONFIG_HOME=${OPTARG};; + r) PRINTER_CONFIG=${OPTARG}.cfg + KLIPPER_SERVICE=klipper_${OPTARG}.service + echo "Repetier-Server specified. Over-riding printer.cfg to [${PRINTER_CONFIG}] & klipper.service to [${KLIPPER_SERVICE}]" + ;; + i) INSTALL=1;; + d) UNINSTALL=1;; + e) STARTER=1;; + s) NOSERVICE=1;; + z) SKIP_UPDATE=1;; + *) usage;; + esac +done + +if [ "${INSTALL}" -eq 1 -a "${UNINSTALL}" -eq 1 ]; then + echo -e "${ERROR}Can't install and uninstall at the same time!" + usage +fi + +verify_not_root +[ -z "${SKIP_UPDATE}" ] && { + self_update # Make sure the repo is up-to-date on correct branch +} +check_octoprint +verify_home_dirs +check_klipper +cleanup_old_klippy_modules + +if [ "$UNINSTALL" -eq 0 ]; then + if [ "${INSTALL}" -eq 1 ]; then + echo -e "${TITLE}$(get_logo "Happy Hare interactive installer...")" + questionaire # Update in memory parameters from questionaire + read_default_config merge # Parses template file parameters and merges into memory + + if [ "${INSTALL_PRINTER_INCLUDES}" == "yes" ]; then + install_printer_includes + fi + else + hardware_config="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" + if [ -f "${hardware_config}" ]; then + echo -e "${TITLE}$(get_logo "Happy Hare upgrading previous install...")" + read_previous_mmu_type # Get MMU type info first + read_default_config # Parses template file parameters into memory + read_previous_config # Update in memory parameters from previous install + elif [ "${STARTER}" -eq 0 ]; then + echo -e "${ERROR}Nothing to upgrade. If you want a new install run with '-i' (interactive) or '-e' (empty config)" + usage + else + # Starter blank install + echo -e "${TITLE}$(get_logo "Happy Hare generating skeletal config...")" + read_default_config # Parses template file parameters into memory + fi + fi + + # Important to update version + FROM_VERSION=${_param_happy_hare_version} + if [ ! "${FROM_VERSION}" == "" ]; then + downgrade=$(awk -v to="$VERSION" -v from="$FROM_VERSION" 'BEGIN {print (to < from) ? "1" : "0"}') + bad_v2v3=$(awk -v to="$VERSION" -v from="$FROM_VERSION" 'BEGIN {print (from < 2.70 && to >= 3.0) ? "1" : "0"}') + if [ "$downgrade" -eq 1 ]; then + echo -e "${WARNING}Trying to update from version ${FROM_VERSION} to ${VERSION}" + echo -e "${ERROR}Automatic 'downgrade' to earlier version is not garanteed. If you encounter startup problems you may" + echo -e "${ERROR}need to manually compare the backed-up 'mmu_parameters.cfg' with current one to restore differences" + elif [ "$bad_v2v3" -eq 1 ]; then + echo -e "${ERROR}Cannot automatically 'upgrade' from version ${FROM_VERSION} to ${VERSION}..." + echo -e "${ERROR}Please upgrade to v2.7.0 or later before attempting v3.0 upgrade" + exit 1 + elif [ ! "${FROM_VERSION}" == "${VERSION}" ]; then + echo -e "${WARNING}Upgrading from version ${FROM_VERSION} to ${VERSION}..." + fi + fi + _param_happy_hare_version=${VERSION} + + # Copy config files updating from in memory parmameters or h/w settings + set +e + copy_config_files + remove_old_config_files + set -e + + # Special upgrades of mmu_hardware.cfg + upgrade_mmu_hardware + + # Link in new components + link_mmu_plugins + install_update_manager + +else + echo + echo -e "${WARNING}You have asked me to remove Happy Hare and cleanup" + echo + yn=$(prompt_yn "Are you sure you want to proceed with deleting Happy Hare?") + echo + case $yn in + y) + unlink_mmu_plugins + uninstall_update_manager + uninstall_printer_includes + uninstall_config_files + echo -e "${INFO}Uninstall complete except for the Happy-Hare directory - you can now safely delete that as well" + ;; + n) + echo -e "${INFO}Well that was a close call! Everything still intact" + echo + exit 0 + ;; + esac +fi + +if [ "$INSTALL" -eq 0 ]; then + if [ "$VERSION" == "2.70" -a "$FROM_VERSION" != "2.70" ]; then + restart_moonraker + fi + restart_klipper +else + echo -e "${WARNING}Klipper not restarted automatically because you need to validate and complete config first" +fi + +if [ "$UNINSTALL" -eq 1 ]; then + echo -e "${EMPHASIZE}" + echo "Done. Sad to see you go (but maybe you'll be back)..." + echo -e "${sad_logo}" +else + echo -e "${TITLE}Done." + echo -e "$(get_logo "Happy Hare ${F_VERSION} Ready...")" +fi diff --git a/installer-dev/.gitignore b/installer-dev/.gitignore new file mode 100644 index 000000000000..f733c4b5fb40 --- /dev/null +++ b/installer-dev/.gitignore @@ -0,0 +1 @@ +config/ diff --git a/installer-dev/Dockerfile b/installer-dev/Dockerfile new file mode 100644 index 000000000000..d012a8b218c6 --- /dev/null +++ b/installer-dev/Dockerfile @@ -0,0 +1,7 @@ +FROM mkuf/klipper:latest + +USER root + +RUN apt update && apt install -y make + +USER klipper diff --git a/installer-dev/README.md b/installer-dev/README.md new file mode 100644 index 000000000000..b9eaff7a6d0a --- /dev/null +++ b/installer-dev/README.md @@ -0,0 +1,27 @@ +# Installer dev + +This provides a quick way to run through the installer in a docker environment, making it more portable. + +> [!NOTE] +> This will create/update configs at `/installer-dev/config`. You may then review the changes there +> or completely remove the files and start from scratch. + +## Usage + +### Full install + +This will run the installer with `-i` which forces it to run through the questionaire. + +```shell +cd ./installer-dev +docker compose run --build --rm install +``` + +### Upgrade + +This will run the installer without `-i` which will perform a config upgrade. + +```shell +cd ./installer-dev +docker compose run --build --rm upgrade +``` diff --git a/installer-dev/docker-compose.yaml b/installer-dev/docker-compose.yaml new file mode 100644 index 000000000000..3f7f54d35fbe --- /dev/null +++ b/installer-dev/docker-compose.yaml @@ -0,0 +1,19 @@ +services: + _base: &base + build: . + environment: + HOME: /opt + volumes: + - ./entrypoint.sh:/entrypoint.sh + - ./config:/opt/printer_data/config + - ../:/opt/Happy-Hare + entrypoint: /entrypoint.sh + command: ./install.sh -sz + + install: + << : *base + command: ./install.sh -siz + + upgrade: + << : *base + command: ./install.sh -sz diff --git a/installer-dev/entrypoint.sh b/installer-dev/entrypoint.sh new file mode 100755 index 000000000000..f06cc4b19d8a --- /dev/null +++ b/installer-dev/entrypoint.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh + +set -x + +# there needs to be at least 1 line in printer.cfg for the installer to be able to add the includes +if [ ! -f "${HOME}/printer_data/config/printer.cfg" ]; then + echo '# Printer Config' >> "${HOME}/printer_data/config/printer.cfg" +fi +cd ~/Happy-Hare + +# run all the arguments as the command +exec "$@" diff --git a/moonraker_update.txt b/moonraker_update.txt new file mode 100644 index 000000000000..ec676271b001 --- /dev/null +++ b/moonraker_update.txt @@ -0,0 +1,11 @@ +[update_manager happy-hare] +type: git_repo +path: ~/Happy-Hare +origin: https://github.com/moggieuk/Happy-Hare.git +primary_branch: main +managed_services: klipper + +[mmu_server] +enable_file_preprocessor: True +enable_toolchange_next_pos: True +update_spoolman_location: True diff --git a/pin_defs b/pin_defs new file mode 100644 index 000000000000..023ea7e9a6cc --- /dev/null +++ b/pin_defs @@ -0,0 +1,870 @@ +#!/bin/bash +# Happy Hare MMU Software +# +# Pin definitions for Installer script. Includes complete set of definitions for each board type +# so impossible setups have blank pins rather than placeholder tokens. Each MCU defines a setup +# for type-A and some for type-B MMU styles. +# +# The defined pin tokens allow for up to 12 gates on type-A MMU's and 4 gates on type-B. No MCU +# can currently support beyond these limits +# +# Copyright (C) 2022-2024 moggieuk#6538 (discord) moggieuk@hotmail.com +# + +# Pins for original EASY-BRD and EASY-BRD with Seed Studio XIAO RP2040 +# Note: uart pin is shared on original EASY-BRD (with different uart addresses) +# +PIN[EASY-BRD,gear_uart_pin]="PA8"; PIN[EASY-BRD-RP2040,gear_uart_pin]="gpio6" +PIN[EASY-BRD,gear_step_pin]="PA4"; PIN[EASY-BRD-RP2040,gear_step_pin]="gpio27" +PIN[EASY-BRD,gear_dir_pin]="PA10"; PIN[EASY-BRD-RP2040,gear_dir_pin]="gpio28" +PIN[EASY-BRD,gear_enable_pin]="PA2"; PIN[EASY-BRD-RP2040,gear_enable_pin]="gpio26" +PIN[EASY-BRD,gear_diag_pin]=""; PIN[EASY-BRD-RP2040,gear_diag_pin]="" +PIN[EASY-BRD,gear_1_uart_pin]=""; PIN[EASY-BRD-RP2040,gear_1_uart_pin]=""; +PIN[EASY-BRD,gear_1_step_pin]=""; PIN[EASY-BRD-RP2040,gear_1_step_pin]=""; +PIN[EASY-BRD,gear_1_dir_pin]=""; PIN[EASY-BRD-RP2040,gear_1_dir_pin]=""; +PIN[EASY-BRD,gear_1_enable_pin]=""; PIN[EASY-BRD-RP2040,gear_1_enable_pin]=""; +PIN[EASY-BRD,gear_1_diag_pin]=""; PIN[EASY-BRD-RP2040,gear_1_diag_pin]=""; +PIN[EASY-BRD,gear_2_uart_pin]=""; PIN[EASY-BRD-RP2040,gear_2_uart_pin]=""; +PIN[EASY-BRD,gear_2_step_pin]=""; PIN[EASY-BRD-RP2040,gear_2_step_pin]=""; +PIN[EASY-BRD,gear_2_dir_pin]=""; PIN[EASY-BRD-RP2040,gear_2_dir_pin]=""; +PIN[EASY-BRD,gear_2_enable_pin]=""; PIN[EASY-BRD-RP2040,gear_2_enable_pin]=""; +PIN[EASY-BRD,gear_2_diag_pin]=""; PIN[EASY-BRD-RP2040,gear_2_diag_pin]=""; +PIN[EASY-BRD,gear_3_uart_pin]=""; PIN[EASY-BRD-RP2040,gear_3_uart_pin]=""; +PIN[EASY-BRD,gear_3_step_pin]=""; PIN[EASY-BRD-RP2040,gear_3_step_pin]=""; +PIN[EASY-BRD,gear_3_dir_pin]=""; PIN[EASY-BRD-RP2040,gear_3_dir_pin]=""; +PIN[EASY-BRD,gear_3_enable_pin]=""; PIN[EASY-BRD-RP2040,gear_3_enable_pin]=""; +PIN[EASY-BRD,gear_3_diag_pin]=""; PIN[EASY-BRD-RP2040,gear_3_diag_pin]=""; +PIN[EASY-BRD,selector_uart_pin]="PA8"; PIN[EASY-BRD-RP2040,selector_uart_pin]="gpio6" +PIN[EASY-BRD,selector_step_pin]="PA9"; PIN[EASY-BRD-RP2040,selector_step_pin]="gpio7" +PIN[EASY-BRD,selector_dir_pin]="PB8"; PIN[EASY-BRD-RP2040,selector_dir_pin]="gpio0" +PIN[EASY-BRD,selector_enable_pin]="PA11"; PIN[EASY-BRD-RP2040,selector_enable_pin]="gpio29" +PIN[EASY-BRD,selector_diag_pin]="PA7"; PIN[EASY-BRD-RP2040,selector_diag_pin]="gpio2" +PIN[EASY-BRD,selector_endstop_pin]="PB9"; PIN[EASY-BRD-RP2040,selector_endstop_pin]="gpio1" +PIN[EASY-BRD,selector_servo_pin]="PA5"; PIN[EASY-BRD-RP2040,selector_servo_pin]="gpio4" +PIN[EASY-BRD,encoder_pin]="PA6"; PIN[EASY-BRD-RP2040,encoder_pin]="gpio3" +PIN[EASY-BRD,neopixel_pin]=""; PIN[EASY-BRD-RP2040,neopixel_pin]="" +PIN[EASY-BRD,gate_sensor_pin]="PA6"; PIN[EASY-BRD-RP2040,gate_sensor_pin]="gpio3"; +PIN[EASY-BRD,pre_gate_0_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_0_pin]=""; +PIN[EASY-BRD,pre_gate_1_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_1_pin]=""; +PIN[EASY-BRD,pre_gate_2_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_2_pin]=""; +PIN[EASY-BRD,pre_gate_3_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_3_pin]=""; +PIN[EASY-BRD,pre_gate_4_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_4_pin]=""; +PIN[EASY-BRD,pre_gate_5_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_5_pin]=""; +PIN[EASY-BRD,pre_gate_6_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_6_pin]=""; +PIN[EASY-BRD,pre_gate_7_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_7_pin]=""; +PIN[EASY-BRD,pre_gate_8_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_8_pin]=""; +PIN[EASY-BRD,pre_gate_9_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_9_pin]=""; +PIN[EASY-BRD,pre_gate_10_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_10_pin]=""; +PIN[EASY-BRD,pre_gate_11_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_11_pin]=""; +PIN[EASY-BRD,gear_sensor_0_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_0_pin]=""; +PIN[EASY-BRD,gear_sensor_1_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_1_pin]=""; +PIN[EASY-BRD,gear_sensor_2_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_2_pin]=""; +PIN[EASY-BRD,gear_sensor_3_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_3_pin]=""; +PIN[EASY-BRD,gear_sensor_4_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_4_pin]=""; +PIN[EASY-BRD,gear_sensor_5_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_5_pin]=""; +PIN[EASY-BRD,gear_sensor_6_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_6_pin]=""; +PIN[EASY-BRD,gear_sensor_7_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_7_pin]=""; +PIN[EASY-BRD,gear_sensor_8_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_8_pin]=""; +PIN[EASY-BRD,gear_sensor_9_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_9_pin]=""; +PIN[EASY-BRD,gear_sensor_10_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_10_pin]=""; +PIN[EASY-BRD,gear_sensor_11_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_10_pin]=""; +# +# Common Type-B MMU pin utilization +# +# TODO + + +# Pins for Mellow EASY-BRD with CANbus (original v1.x and v2) +# +PIN[MELLOW-EASY-BRD-CAN,gear_uart_pin]="gpio9"; PIN[MELLOW-EASY-BRD-CANv2,gear_uart_pin]="gpio9"; +PIN[MELLOW-EASY-BRD-CAN,gear_step_pin]="gpio7"; PIN[MELLOW-EASY-BRD-CANv2,gear_step_pin]="gpio7"; +PIN[MELLOW-EASY-BRD-CAN,gear_dir_pin]="gpio8"; PIN[MELLOW-EASY-BRD-CANv2,gear_dir_pin]="gpio8"; +PIN[MELLOW-EASY-BRD-CAN,gear_enable_pin]="gpio6"; PIN[MELLOW-EASY-BRD-CANv2,gear_enable_pin]="gpio6"; +PIN[MELLOW-EASY-BRD-CAN,gear_diag_pin]="gpio23"; PIN[MELLOW-EASY-BRD-CANv2,gear_diag_pin]=""; # v2: Dup with encoder (gpio15) +PIN[MELLOW-EASY-BRD-CAN,gear_1_uart_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_uart_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_1_step_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_step_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_1_dir_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_dir_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_1_enable_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_enable_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_1_diag_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_diag_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_2_uart_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_uart_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_2_step_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_step_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_2_dir_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_dir_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_2_enable_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_enable_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_2_diag_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_diag_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_3_uart_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_uart_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_3_step_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_step_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_3_dir_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_dir_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_3_enable_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_enable_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_3_diag_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_diag_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,selector_uart_pin]="gpio0"; PIN[MELLOW-EASY-BRD-CANv2,selector_uart_pin]="gpio2"; +PIN[MELLOW-EASY-BRD-CAN,selector_step_pin]="gpio2"; PIN[MELLOW-EASY-BRD-CANv2,selector_step_pin]="gpio4"; +PIN[MELLOW-EASY-BRD-CAN,selector_dir_pin]="gpio1"; PIN[MELLOW-EASY-BRD-CANv2,selector_dir_pin]="gpio3"; +PIN[MELLOW-EASY-BRD-CAN,selector_enable_pin]="gpio3"; PIN[MELLOW-EASY-BRD-CANv2,selector_enable_pin]="gpio5"; +PIN[MELLOW-EASY-BRD-CAN,selector_diag_pin]="gpio22"; PIN[MELLOW-EASY-BRD-CANv2,selector_diag_pin]="gpio20"; # v2: Dup with endstop (gpio20) +PIN[MELLOW-EASY-BRD-CAN,selector_endstop_pin]="gpio20"; PIN[MELLOW-EASY-BRD-CANv2,selector_endstop_pin]="gpio20"; # Endstop +PIN[MELLOW-EASY-BRD-CAN,selector_servo_pin]="gpio21"; PIN[MELLOW-EASY-BRD-CANv2,selector_servo_pin]="gpio21"; # Servo +PIN[MELLOW-EASY-BRD-CAN,encoder_pin]="gpio15"; PIN[MELLOW-EASY-BRD-CANv2,encoder_pin]="gpio15"; # Encoder +PIN[MELLOW-EASY-BRD-CAN,neopixel_pin]="gpio14"; PIN[MELLOW-EASY-BRD-CANv2,neopixel_pin]="gpio14"; # v1: Extra / v2: RGB +PIN[MELLOW-EASY-BRD-CAN,gate_sensor_pin]="gpio15"; PIN[MELLOW-EASY-BRD-CANv2,gate_sensor_pin]="gpio15"; # Encoder (Alt) +PIN[MELLOW-EASY-BRD-CAN,pre_gate_0_pin]="gpio10"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_0_pin]="gpio24"; # v1: Exp 5 / v2: Exp 3 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_1_pin]="gpio26"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_1_pin]="gpio22"; # v1: Exp 6 / v2: Exp 4 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_2_pin]="gpio11"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_2_pin]="gpio25"; # v1: Exp 7 / v2: Exp 5 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_3_pin]="gpio27"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_3_pin]="gpio23"; # v1: Exp 8 / v2: Exp 6 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_4_pin]="gpio12"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_4_pin]="gpio13"; # v1: Exp 9 / v2: Exp 7 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_5_pin]="gpio28"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_5_pin]="gpio26"; # v1: Exp 10 / v2: Exp 8 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_6_pin]="gpio24"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_6_pin]="gpio12"; # v1: Exp 11 / v2: Exp 9 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_7_pin]="gpio29"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_7_pin]="gpio27"; # v1: Exp 12 / v2: Exp 10 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_8_pin]="gpio13"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_8_pin]="gpio11"; # v1: Exp 13 / v2: Exp 11 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_9_pin]="gpio25"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_9_pin]="gpio28"; # v1: Exp 14 / v2: Exp 12 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_10_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_10_pin]="gpio10"; # v2: Exp 13 +PIN[MELLOW-EASY-BRD-CAN,pre_gate_11_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_11_pin]="gpio29"; # v2: Exp 14 +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_0_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_0_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_1_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_1_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_2_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_2_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_3_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_3_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_4_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_4_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_5_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_5_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_6_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_6_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_7_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_7_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_8_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_8_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_9_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_9_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_10_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_10_pin]=""; +PIN[MELLOW-EASY-BRD-CAN,gear_sensor_11_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_10_pin]=""; +# +# Common Type-B MMU pin utilization +# +# TODO + + +# Pins for Fysetc Burrows ERB board (original v1 and v2) +# +PIN[ERB,gear_uart_pin]="gpio20"; PIN[ERBv2,gear_uart_pin]="gpio11"; +PIN[ERB,gear_step_pin]="gpio10"; PIN[ERBv2,gear_step_pin]="gpio10"; +PIN[ERB,gear_dir_pin]="gpio9"; PIN[ERBv2,gear_dir_pin]="gpio9"; +PIN[ERB,gear_enable_pin]="gpio8"; PIN[ERBv2,gear_enable_pin]="gpio8"; +PIN[ERB,gear_diag_pin]="gpio13"; PIN[ERBv2,gear_diag_pin]="gpio13"; +PIN[ERB,gear_1_uart_pin]=""; PIN[ERBv2,gear_1_uart_pin]=""; +PIN[ERB,gear_1_step_pin]=""; PIN[ERBv2,gear_1_step_pin]=""; +PIN[ERB,gear_1_dir_pin]=""; PIN[ERBv2,gear_1_dir_pin]=""; +PIN[ERB,gear_1_enable_pin]=""; PIN[ERBv2,gear_1_enable_pin]=""; +PIN[ERB,gear_1_diag_pin]=""; PIN[ERBv2,gear_1_diag_pin]=""; +PIN[ERB,gear_2_uart_pin]=""; PIN[ERBv2,gear_2_uart_pin]=""; +PIN[ERB,gear_2_step_pin]=""; PIN[ERBv2,gear_2_step_pin]=""; +PIN[ERB,gear_2_dir_pin]=""; PIN[ERBv2,gear_2_dir_pin]=""; +PIN[ERB,gear_2_enable_pin]=""; PIN[ERBv2,gear_2_enable_pin]=""; +PIN[ERB,gear_2_diag_pin]=""; PIN[ERBv2,gear_2_diag_pin]=""; +PIN[ERB,gear_3_uart_pin]=""; PIN[ERBv2,gear_3_uart_pin]=""; +PIN[ERB,gear_3_step_pin]=""; PIN[ERBv2,gear_3_step_pin]=""; +PIN[ERB,gear_3_dir_pin]=""; PIN[ERBv2,gear_3_dir_pin]=""; +PIN[ERB,gear_3_enable_pin]=""; PIN[ERBv2,gear_3_enable_pin]=""; +PIN[ERB,gear_3_diag_pin]=""; PIN[ERBv2,gear_3_diag_pin]=""; +PIN[ERB,selector_uart_pin]="gpio17"; PIN[ERBv2,selector_uart_pin]="gpio17"; +PIN[ERB,selector_step_pin]="gpio16"; PIN[ERBv2,selector_step_pin]="gpio16"; +PIN[ERB,selector_dir_pin]="gpio15"; PIN[ERBv2,selector_dir_pin]="gpio15"; +PIN[ERB,selector_enable_pin]="gpio14"; PIN[ERBv2,selector_enable_pin]="gpio14"; +PIN[ERB,selector_diag_pin]="gpio19"; PIN[ERBv2,selector_diag_pin]="gpio19"; +PIN[ERB,selector_endstop_pin]="gpio24"; PIN[ERBv2,selector_endstop_pin]="gpio24"; +PIN[ERB,selector_servo_pin]="gpio23"; PIN[ERBv2,selector_servo_pin]="gpio23"; +PIN[ERB,encoder_pin]="gpio22"; PIN[ERBv2,encoder_pin]="gpio22"; +PIN[ERB,neopixel_pin]="gpio21"; PIN[ERBv2,neopixel_pin]="gpio21"; +PIN[ERB,gate_sensor_pin]="gpio22"; PIN[ERBv2,gate_sensor_pin]="gpio25"; # Hall Effect +PIN[ERB,pre_gate_0_pin]="gpio0"; PIN[ERBv2,pre_gate_0_pin]="gpio12"; +PIN[ERB,pre_gate_1_pin]="gpio1"; PIN[ERBv2,pre_gate_1_pin]="gpio18"; +PIN[ERB,pre_gate_2_pin]="gpio2"; PIN[ERBv2,pre_gate_2_pin]="gpio2"; +PIN[ERB,pre_gate_3_pin]="gpio3"; PIN[ERBv2,pre_gate_3_pin]="gpio3"; +PIN[ERB,pre_gate_4_pin]="gpio4"; PIN[ERBv2,pre_gate_4_pin]="gpio4"; +PIN[ERB,pre_gate_5_pin]="gpio5"; PIN[ERBv2,pre_gate_5_pin]="gpio5"; +PIN[ERB,pre_gate_6_pin]="gpio6"; PIN[ERBv2,pre_gate_6_pin]="gpio6"; +PIN[ERB,pre_gate_7_pin]="gpio7"; PIN[ERBv2,pre_gate_7_pin]="gpio7"; +PIN[ERB,pre_gate_8_pin]="gpio26"; PIN[ERBv2,pre_gate_8_pin]="gpio26"; +PIN[ERB,pre_gate_9_pin]="gpio27"; PIN[ERBv2,pre_gate_9_pin]="gpio27"; +PIN[ERB,pre_gate_10_pin]="gpio28"; PIN[ERBv2,pre_gate_10_pin]="gpio28"; +PIN[ERB,pre_gate_11_pin]="gpio29"; PIN[ERBv2,pre_gate_11_pin]="gpio29"; +PIN[ERB,gear_sensor_0_pin]=""; PIN[ERBv2,gear_sensor_0_pin]=""; +PIN[ERB,gear_sensor_1_pin]=""; PIN[ERBv2,gear_sensor_1_pin]=""; +PIN[ERB,gear_sensor_2_pin]=""; PIN[ERBv2,gear_sensor_2_pin]=""; +PIN[ERB,gear_sensor_3_pin]=""; PIN[ERBv2,gear_sensor_3_pin]=""; +PIN[ERB,gear_sensor_4_pin]=""; PIN[ERBv2,gear_sensor_4_pin]=""; +PIN[ERB,gear_sensor_5_pin]=""; PIN[ERBv2,gear_sensor_5_pin]=""; +PIN[ERB,gear_sensor_6_pin]=""; PIN[ERBv2,gear_sensor_6_pin]=""; +PIN[ERB,gear_sensor_7_pin]=""; PIN[ERBv2,gear_sensor_7_pin]=""; +PIN[ERB,gear_sensor_8_pin]=""; PIN[ERBv2,gear_sensor_8_pin]=""; +PIN[ERB,gear_sensor_9_pin]=""; PIN[ERBv2,gear_sensor_9_pin]=""; +PIN[ERB,gear_sensor_10_pin]=""; PIN[ERBv2,gear_sensor_10_pin]=""; +PIN[ERB,gear_sensor_11_pin]=""; PIN[ERBv2,gear_sensor_10_pin]=""; +# +# Common Type-B MMU pin utilization +# +# TODO + + +# Pins for BTT MMB board (gear on motor1, selector on motor2, endstop on STP11, optional gate sensor on STP1 if no gear DIAG use) +# Note BTT MMB v1.1 Board switched M1_enable and STP4 +# +PIN[MMB10,gear_uart_pin]="PA10"; PIN[MMB11,gear_uart_pin]="PA10"; # M1 +PIN[MMB10,gear_step_pin]="PB15"; PIN[MMB11,gear_step_pin]="PB15"; +PIN[MMB10,gear_dir_pin]="PB14"; PIN[MMB11,gear_dir_pin]="PB14"; +PIN[MMB10,gear_enable_pin]="PA8"; PIN[MMB11,gear_enable_pin]="PB8"; +PIN[MMB10,gear_diag_pin]="PA3"; PIN[MMB11,gear_diag_pin]="PA3"; # Aka STP1 +PIN[MMB10,gear_1_uart_pin]=""; PIN[MMB11,gear_1_uart_pin]=""; +PIN[MMB10,gear_1_step_pin]=""; PIN[MMB11,gear_1_step_pin]=""; +PIN[MMB10,gear_1_dir_pin]=""; PIN[MMB11,gear_1_dir_pin]=""; +PIN[MMB10,gear_1_enable_pin]=""; PIN[MMB11,gear_1_enable_pin]=""; +PIN[MMB10,gear_1_diag_pin]=""; PIN[MMB11,gear_1_diag_pin]=""; +PIN[MMB10,gear_2_uart_pin]=""; PIN[MMB11,gear_2_uart_pin]=""; +PIN[MMB10,gear_2_step_pin]=""; PIN[MMB11,gear_2_step_pin]=""; +PIN[MMB10,gear_2_dir_pin]=""; PIN[MMB11,gear_2_dir_pin]=""; +PIN[MMB10,gear_2_enable_pin]=""; PIN[MMB11,gear_2_enable_pin]=""; +PIN[MMB10,gear_2_diag_pin]=""; PIN[MMB11,gear_2_diag_pin]=""; +PIN[MMB10,gear_3_uart_pin]=""; PIN[MMB11,gear_3_uart_pin]=""; +PIN[MMB10,gear_3_step_pin]=""; PIN[MMB11,gear_3_step_pin]=""; +PIN[MMB10,gear_3_dir_pin]=""; PIN[MMB11,gear_3_dir_pin]=""; +PIN[MMB10,gear_3_enable_pin]=""; PIN[MMB11,gear_3_enable_pin]=""; +PIN[MMB10,gear_3_diag_pin]=""; PIN[MMB11,gear_3_diag_pin]=""; +PIN[MMB10,selector_uart_pin]="PC7"; PIN[MMB11,selector_uart_pin]="PC7"; # M2 +PIN[MMB10,selector_step_pin]="PD2"; PIN[MMB11,selector_step_pin]="PD2"; +PIN[MMB10,selector_dir_pin]="PB13"; PIN[MMB11,selector_dir_pin]="PB13"; +PIN[MMB10,selector_enable_pin]="PD1"; PIN[MMB11,selector_enable_pin]="PD1"; +PIN[MMB10,selector_diag_pin]="PA4"; PIN[MMB11,selector_diag_pin]="PA4"; # Aka STP2 +PIN[MMB10,selector_endstop_pin]="PB2"; PIN[MMB11,selector_endstop_pin]="PB2"; # STP11 +PIN[MMB10,selector_servo_pin]="PA0"; PIN[MMB11,selector_servo_pin]="PA0"; +PIN[MMB10,encoder_pin]="PA1"; PIN[MMB11,encoder_pin]="PA1"; +PIN[MMB10,neopixel_pin]="PA2"; PIN[MMB11,neopixel_pin]="PA2"; +PIN[MMB10,gate_sensor_pin]="PA3"; PIN[MMB11,gate_sensor_pin]="PA3"; # STP1 (if not DIAG) +PIN[MMB10,pre_gate_0_pin]="PB9"; PIN[MMB11,pre_gate_0_pin]="PB9"; # STP3 +PIN[MMB10,pre_gate_1_pin]="PB8"; PIN[MMB11,pre_gate_1_pin]="PA8"; # STP4 +PIN[MMB10,pre_gate_2_pin]="PC15"; PIN[MMB11,pre_gate_2_pin]="PC15"; # STP5 +PIN[MMB10,pre_gate_3_pin]="PC13"; PIN[MMB11,pre_gate_3_pin]="PC13"; # STP6 +PIN[MMB10,pre_gate_4_pin]="PC14"; PIN[MMB11,pre_gate_4_pin]="PC14"; # STP7 +PIN[MMB10,pre_gate_5_pin]="PB12"; PIN[MMB11,pre_gate_5_pin]="PB12"; # STP8 +PIN[MMB10,pre_gate_6_pin]="PB11"; PIN[MMB11,pre_gate_6_pin]="PB11"; # STP9 +PIN[MMB10,pre_gate_7_pin]="PB10"; PIN[MMB11,pre_gate_7_pin]="PB10"; # STP10 +PIN[MMB10,pre_gate_8_pin]=""; PIN[MMB11,pre_gate_8_pin]=""; +PIN[MMB10,pre_gate_9_pin]=""; PIN[MMB11,pre_gate_9_pin]=""; +PIN[MMB10,pre_gate_10_pin]=""; PIN[MMB11,pre_gate_10_pin]=""; +PIN[MMB10,pre_gate_11_pin]=""; PIN[MMB11,pre_gate_11_pin]=""; +PIN[MMB10,gear_sensor_0_pin]=""; PIN[MMB11,gear_sensor_0_pin]=""; +PIN[MMB10,gear_sensor_1_pin]=""; PIN[MMB11,gear_sensor_1_pin]=""; +PIN[MMB10,gear_sensor_2_pin]=""; PIN[MMB11,gear_sensor_2_pin]=""; +PIN[MMB10,gear_sensor_3_pin]=""; PIN[MMB11,gear_sensor_3_pin]=""; +PIN[MMB10,gear_sensor_4_pin]=""; PIN[MMB11,gear_sensor_4_pin]=""; +PIN[MMB10,gear_sensor_5_pin]=""; PIN[MMB11,gear_sensor_5_pin]=""; +PIN[MMB10,gear_sensor_6_pin]=""; PIN[MMB11,gear_sensor_6_pin]=""; +PIN[MMB10,gear_sensor_7_pin]=""; PIN[MMB11,gear_sensor_7_pin]=""; +PIN[MMB10,gear_sensor_8_pin]=""; PIN[MMB11,gear_sensor_8_pin]=""; +PIN[MMB10,gear_sensor_9_pin]=""; PIN[MMB11,gear_sensor_9_pin]=""; +PIN[MMB10,gear_sensor_10_pin]=""; PIN[MMB11,gear_sensor_10_pin]=""; +PIN[MMB10,gear_sensor_11_pin]=""; PIN[MMB11,gear_sensor_11_pin]=""; +# +# Common Type-B MMU pin utilization +# +PIN[B,MMB10,gear_uart_pin]="PA10"; PIN[B,MMB11,gear_uart_pin]="PA10"; # M1 +PIN[B,MMB10,gear_step_pin]="PB15"; PIN[B,MMB11,gear_step_pin]="PB15"; +PIN[B,MMB10,gear_dir_pin]="PB14"; PIN[B,MMB11,gear_dir_pin]="PB14"; +PIN[B,MMB10,gear_enable_pin]="PA8"; PIN[B,MMB11,gear_enable_pin]="PB8"; # !Reversed on v1.1 +PIN[B,MMB10,gear_diag_pin]="PA3"; PIN[B,MMB11,gear_diag_pin]="PA3"; # Aka STP1 +PIN[B,MMB10,gear_1_uart_pin]="PC7"; PIN[B,MMB11,gear_1_uart_pin]="PC7"; # M2 +PIN[B,MMB10,gear_1_step_pin]="PD2"; PIN[B,MMB11,gear_1_step_pin]="PD2"; +PIN[B,MMB10,gear_1_dir_pin]="PB13"; PIN[B,MMB11,gear_1_dir_pin]="PB13"; +PIN[B,MMB10,gear_1_enable_pin]="PD1"; PIN[B,MMB11,gear_1_enable_pin]="PD1"; +PIN[B,MMB10,gear_1_diag_pin]="PA4"; PIN[B,MMB11,gear_1_diag_pin]="PA4"; # Aka STP2 +PIN[B,MMB10,gear_2_uart_pin]="PC6"; PIN[B,MMB11,gear_2_uart_pin]="PC6"; # M3 +PIN[B,MMB10,gear_2_step_pin]="PD0"; PIN[B,MMB11,gear_2_step_pin]="PD0"; +PIN[B,MMB10,gear_2_dir_pin]="PD3"; PIN[B,MMB11,gear_2_dir_pin]="PD3"; +PIN[B,MMB10,gear_2_enable_pin]="PA15"; PIN[B,MMB11,gear_2_enable_pin]="PA15"; +PIN[B,MMB10,gear_2_diag_pin]="PB9"; PIN[B,MMB11,gear_2_diag_pin]="PB9"; # Aka STP3 +PIN[B,MMB10,gear_3_uart_pin]="PA9"; PIN[B,MMB11,gear_3_uart_pin]="PA9"; # M4 +PIN[B,MMB10,gear_3_step_pin]="PB6"; PIN[B,MMB11,gear_3_step_pin]="PB6"; +PIN[B,MMB10,gear_3_dir_pin]="PB7"; PIN[B,MMB11,gear_3_dir_pin]="PB7"; +PIN[B,MMB10,gear_3_enable_pin]="PB5"; PIN[B,MMB11,gear_3_enable_pin]="PB5"; +PIN[B,MMB10,gear_3_diag_pin]="PB8"; PIN[B,MMB11,gear_3_diag_pin]="PB8"; # Aka STP4 +PIN[B,MMB10,selector_uart_pin]=""; PIN[B,MMB11,selector_uart_pin]=""; +PIN[B,MMB10,selector_step_pin]=""; PIN[B,MMB11,selector_step_pin]=""; +PIN[B,MMB10,selector_dir_pin]=""; PIN[B,MMB11,selector_dir_pin]=""; +PIN[B,MMB10,selector_enable_pin]=""; PIN[B,MMB11,selector_enable_pin]=""; +PIN[B,MMB10,selector_diag_pin]=""; PIN[B,MMB11,selector_diag_pin]=""; +PIN[B,MMB10,selector_endstop_pin]=""; PIN[B,MMB11,selector_endstop_pin]=""; +PIN[B,MMB10,selector_servo_pin]=""; PIN[B,MMB11,selector_servo_pin]=""; +PIN[B,MMB10,encoder_pin]="PA1"; PIN[B,MMB11,encoder_pin]="PA1"; +PIN[B,MMB10,neopixel_pin]="PA2"; PIN[B,MMB11,neopixel_pin]="PA2"; +PIN[B,MMB10,gate_sensor_pin]="PB11"; PIN[B,MMB11,gate_sensor_pin]="PB11"; # STP9 +PIN[B,MMB10,pre_gate_0_pin]="PA3"; PIN[B,MMB11,pre_gate_0_pin]="PA3"; # STP1 +PIN[B,MMB10,pre_gate_1_pin]="PA4"; PIN[B,MMB11,pre_gate_1_pin]="PA4"; # STP2 +PIN[B,MMB10,pre_gate_2_pin]="PB9"; PIN[B,MMB11,pre_gate_2_pin]="PB9"; # STP3 +PIN[B,MMB10,pre_gate_3_pin]="PB8"; PIN[B,MMB11,pre_gate_3_pin]="PA8"; # STP4 !Reversed on v1.1 +PIN[B,MMB10,pre_gate_4_pin]=""; PIN[B,MMB11,pre_gate_4_pin]=""; +PIN[B,MMB10,pre_gate_5_pin]=""; PIN[B,MMB11,pre_gate_5_pin]=""; +PIN[B,MMB10,pre_gate_6_pin]=""; PIN[B,MMB11,pre_gate_6_pin]=""; +PIN[B,MMB10,pre_gate_7_pin]=""; PIN[B,MMB11,pre_gate_7_pin]=""; +PIN[B,MMB10,pre_gate_8_pin]=""; PIN[B,MMB11,pre_gate_8_pin]=""; +PIN[B,MMB10,pre_gate_9_pin]=""; PIN[B,MMB11,pre_gate_9_pin]=""; +PIN[B,MMB10,pre_gate_10_pin]=""; PIN[B,MMB11,pre_gate_10_pin]=""; +PIN[B,MMB10,pre_gate_11_pin]=""; PIN[B,MMB11,pre_gate_11_pin]=""; +PIN[B,MMB10,gear_sensor_0_pin]="PC15"; PIN[B,MMB11,gear_sensor_0_pin]="PC15"; # STP5 +PIN[B,MMB10,gear_sensor_1_pin]="PC13"; PIN[B,MMB11,gear_sensor_1_pin]="PC13"; # STP6 +PIN[B,MMB10,gear_sensor_2_pin]="PC14"; PIN[B,MMB11,gear_sensor_2_pin]="PC14"; # STP7 +PIN[B,MMB10,gear_sensor_3_pin]="PB12"; PIN[B,MMB11,gear_sensor_3_pin]="PB12"; # STP8 +PIN[B,MMB10,gear_sensor_4_pin]=""; PIN[B,MMB11,gear_sensor_4_pin]=""; +PIN[B,MMB10,gear_sensor_5_pin]=""; PIN[B,MMB11,gear_sensor_5_pin]=""; +PIN[B,MMB10,gear_sensor_6_pin]=""; PIN[B,MMB11,gear_sensor_6_pin]=""; +PIN[B,MMB10,gear_sensor_7_pin]=""; PIN[B,MMB11,gear_sensor_7_pin]=""; +PIN[B,MMB10,gear_sensor_8_pin]=""; PIN[B,MMB11,gear_sensor_8_pin]=""; +PIN[B,MMB10,gear_sensor_9_pin]=""; PIN[B,MMB11,gear_sensor_9_pin]=""; +PIN[B,MMB10,gear_sensor_10_pin]=""; PIN[B,MMB11,gear_sensor_10_pin]=""; +PIN[B,MMB10,gear_sensor_11_pin]=""; PIN[B,MMB11,gear_sensor_11_pin]=""; +PIN[B,MMB10,espooler_en_0_pin]=""; PIN[B,MMB11,espooler_en_0_pin]=""; +PIN[B,MMB10,espooler_en_1_pin]=""; PIN[B,MMB11,espooler_en_1_pin]=""; +PIN[B,MMB10,espooler_en_2_pin]=""; PIN[B,MMB11,espooler_en_2_pin]=""; +PIN[B,MMB10,espooler_en_3_pin]=""; PIN[B,MMB11,espooler_en_3_pin]=""; +PIN[B,MMB10,espooler_rwd_0_pin]="PA0"; PIN[B,MMB11,espooler_rwd_0_pin]="PA0"; +PIN[B,MMB10,espooler_rwd_1_pin]="PA1"; PIN[B,MMB11,espooler_rwd_1_pin]="PA1"; +PIN[B,MMB10,espooler_rwd_2_pin]="PB10"; PIN[B,MMB11,espooler_rwd_2_pin]="PB10"; +PIN[B,MMB10,espooler_rwd_3_pin]="PB2"; PIN[B,MMB11,espooler_rwd_3_pin]="PB2"; +PIN[B,MMB10,espooler_fwd_0_pin]=""; PIN[B,MMB11,espooler_fwd_0_pin]=""; +PIN[B,MMB10,espooler_fwd_1_pin]=""; PIN[B,MMB11,espooler_fwd_1_pin]=""; +PIN[B,MMB10,espooler_fwd_2_pin]=""; PIN[B,MMB11,espooler_fwd_2_pin]=""; +PIN[B,MMB10,espooler_fwd_3_pin]=""; PIN[B,MMB11,espooler_fwd_3_pin]=""; +# Spare: PB10 (STP10), PB2 (STP11) PB10 (STP10), PB2 (STP11) + + +# Type A pins for BTT MMB board v2.0 +# (gear on motor1, selector on motor2, endstop on STP11, optional gate sensor on STP1 if no gear DIAG use) +# +PIN[MMB20,gear_uart_pin]="PB5"; # M1 CS Pin +PIN[MMB20,gear_step_pin]="PD4"; # M1 STEP Pin +PIN[MMB20,gear_dir_pin]="PD3"; # M1 DIR Pin +PIN[MMB20,gear_enable_pin]="PD5"; # M1 EN Pin +PIN[MMB20,gear_diag_pin]="PB8"; # M1 DIAG Pin +PIN[MMB20,gear_1_uart_pin]=""; +PIN[MMB20,gear_1_step_pin]=""; +PIN[MMB20,gear_1_dir_pin]=""; +PIN[MMB20,gear_1_enable_pin]=""; +PIN[MMB20,gear_1_diag_pin]=""; +PIN[MMB20,gear_2_uart_pin]=""; +PIN[MMB20,gear_2_step_pin]=""; +PIN[MMB20,gear_2_dir_pin]=""; +PIN[MMB20,gear_2_enable_pin]=""; +PIN[MMB20,gear_2_diag_pin]=""; +PIN[MMB20,gear_3_uart_pin]=""; +PIN[MMB20,gear_3_step_pin]=""; +PIN[MMB20,gear_3_dir_pin]=""; +PIN[MMB20,gear_3_enable_pin]=""; +PIN[MMB20,gear_3_diag_pin]=""; +PIN[MMB20,selector_uart_pin]="PB4"; # M2 CS Pin +PIN[MMB20,selector_step_pin]="PC9"; # M2 STEP Pin +PIN[MMB20,selector_dir_pin]="PC8"; # M2 DIR Pin +PIN[MMB20,selector_enable_pin]="PD2"; # M2 EN Pin +PIN[MMB20,selector_diag_pin]="PB8"; # M2 DIAG Pin +PIN[MMB20,selector_endstop_pin]="PA15"; # STOP1 +PIN[MMB20,selector_servo_pin]="PA1"; # MOT1 +PIN[MMB20,encoder_pin]="PC2"; # SENSOR +PIN[MMB20,neopixel_pin]="PC3"; # RGB +PIN[MMB20,gate_sensor_pin]=""; # STOP3 (if not DIAG) +PIN[MMB20,pre_gate_0_pin]="PC6"; # STOP5 +PIN[MMB20,pre_gate_1_pin]="PA8"; # STOP6 +PIN[MMB20,pre_gate_2_pin]="PB11"; # STOP7 +PIN[MMB20,pre_gate_3_pin]="PB2"; # STOP8 +PIN[MMB20,pre_gate_4_pin]="PB0"; # STOP9 +PIN[MMB20,pre_gate_5_pin]="PC4"; # STOP10 +PIN[MMB20,pre_gate_6_pin]="PC5"; # STOP11 +PIN[MMB20,pre_gate_7_pin]="PB1"; # STOP12 +PIN[MMB20,pre_gate_8_pin]=""; # STOP13 +PIN[MMB20,pre_gate_9_pin]=""; # STOP14 +PIN[MMB20,pre_gate_10_pin]=""; # STOP15 +PIN[MMB20,pre_gate_11_pin]=""; # STOP16 +PIN[MMB20,gear_sensor_0_pin]=""; +PIN[MMB20,gear_sensor_1_pin]=""; +PIN[MMB20,gear_sensor_2_pin]=""; +PIN[MMB20,gear_sensor_3_pin]=""; +PIN[MMB20,gear_sensor_4_pin]=""; +PIN[MMB20,gear_sensor_5_pin]=""; +PIN[MMB20,gear_sensor_6_pin]=""; +PIN[MMB20,gear_sensor_7_pin]=""; +PIN[MMB20,gear_sensor_8_pin]=""; +PIN[MMB20,gear_sensor_9_pin]=""; +PIN[MMB20,gear_sensor_10_pin]=""; +PIN[MMB20,gear_sensor_11_pin]=""; +# +# Common Type-B MMU pin utilization for BTT MMB v2.0 +# +PIN[B,MMB20,gear_uart_pin]="PB5"; +PIN[B,MMB20,gear_step_pin]="PD4"; +PIN[B,MMB20,gear_dir_pin]="PD3"; +PIN[B,MMB20,gear_enable_pin]="PD5"; +PIN[B,MMB20,gear_diag_pin]="PB9"; +PIN[B,MMB20,gear_1_uart_pin]="PB4"; +PIN[B,MMB20,gear_1_step_pin]="PC9"; +PIN[B,MMB20,gear_1_dir_pin]="PC8"; +PIN[B,MMB20,gear_1_enable_pin]="PD2"; +PIN[B,MMB20,gear_1_diag_pin]="PB8"; +PIN[B,MMB20,gear_2_uart_pin]="PB3"; +PIN[B,MMB20,gear_2_step_pin]="PC15"; +PIN[B,MMB20,gear_2_dir_pin]="PC11"; +PIN[B,MMB20,gear_2_enable_pin]="PC10"; +PIN[B,MMB20,gear_2_diag_pin]="PB7"; +PIN[B,MMB20,gear_3_uart_pin]="PD6"; +PIN[B,MMB20,gear_3_step_pin]="PC13"; +PIN[B,MMB20,gear_3_dir_pin]="PC12"; +PIN[B,MMB20,gear_3_enable_pin]="PC14"; +PIN[B,MMB20,gear_3_diag_pin]="PB6"; +PIN[B,MMB20,selector_uart_pin]=""; +PIN[B,MMB20,selector_step_pin]=""; +PIN[B,MMB20,selector_dir_pin]=""; +PIN[B,MMB20,selector_enable_pin]=""; +PIN[B,MMB20,selector_diag_pin]=""; +PIN[B,MMB20,selector_endstop_pin]=""; +PIN[B,MMB20,selector_servo_pin]=""; +PIN[B,MMB20,encoder_pin]="PC2"; +PIN[B,MMB20,neopixel_pin]="PC3"; +PIN[B,MMB20,gate_sensor_pin]="PA15"; +PIN[B,MMB20,pre_gate_0_pin]="PC7"; +PIN[B,MMB20,pre_gate_1_pin]="PA9"; +PIN[B,MMB20,pre_gate_2_pin]="PB12"; +PIN[B,MMB20,pre_gate_3_pin]="PB10"; +PIN[B,MMB20,pre_gate_4_pin]=""; +PIN[B,MMB20,pre_gate_5_pin]=""; +PIN[B,MMB20,pre_gate_6_pin]=""; +PIN[B,MMB20,pre_gate_7_pin]=""; +PIN[B,MMB20,pre_gate_8_pin]=""; +PIN[B,MMB20,pre_gate_9_pin]=""; +PIN[B,MMB20,pre_gate_10_pin]=""; +PIN[B,MMB20,pre_gate_11_pin]=""; +PIN[B,MMB20,gear_sensor_0_pin]="PC6"; +PIN[B,MMB20,gear_sensor_1_pin]="PA8"; +PIN[B,MMB20,gear_sensor_2_pin]="PB11"; +PIN[B,MMB20,gear_sensor_3_pin]="PB2"; +PIN[B,MMB20,gear_sensor_4_pin]=""; +PIN[B,MMB20,gear_sensor_5_pin]=""; +PIN[B,MMB20,gear_sensor_6_pin]=""; +PIN[B,MMB20,gear_sensor_7_pin]=""; +PIN[B,MMB20,gear_sensor_8_pin]=""; +PIN[B,MMB20,gear_sensor_9_pin]=""; +PIN[B,MMB20,gear_sensor_10_pin]=""; +PIN[B,MMB20,gear_sensor_11_pin]=""; +PIN[B,MMB20,espooler_en_0_pin]=""; +PIN[B,MMB20,espooler_en_1_pin]=""; +PIN[B,MMB20,espooler_en_2_pin]=""; +PIN[B,MMB20,espooler_en_3_pin]=""; +PIN[B,MMB20,espooler_rwd_0_pin]="PB1"; +PIN[B,MMB20,espooler_rwd_1_pin]="PC5"; +PIN[B,MMB20,espooler_rwd_2_pin]="PB0"; +PIN[B,MMB20,espooler_rwd_3_pin]="PC4"; +PIN[B,MMB20,espooler_fwd_0_pin]=""; +PIN[B,MMB20,espooler_fwd_1_pin]=""; +PIN[B,MMB20,espooler_fwd_2_pin]=""; +PIN[B,MMB20,espooler_fwd_3_pin]=""; + + +# Pins for AFC Lite board +# Type-B MMU pin utilization +# +PIN[B,AFC_LITE_1,gear_uart_pin]="PD5"; +PIN[B,AFC_LITE_1,gear_step_pin]="PD4"; +PIN[B,AFC_LITE_1,gear_dir_pin]="PD3"; +PIN[B,AFC_LITE_1,gear_enable_pin]="PD6"; +PIN[B,AFC_LITE_1,gear_diag_pin]="PD2"; +PIN[B,AFC_LITE_1,gear_1_uart_pin]="PD0"; +PIN[B,AFC_LITE_1,gear_1_step_pin]="PC12"; +PIN[B,AFC_LITE_1,gear_1_dir_pin]="PC11"; +PIN[B,AFC_LITE_1,gear_1_enable_pin]="PD1"; +PIN[B,AFC_LITE_1,gear_1_diag_pin]="PC10"; +PIN[B,AFC_LITE_1,gear_2_uart_pin]="PE1"; +PIN[B,AFC_LITE_1,gear_2_step_pin]="PE2"; +PIN[B,AFC_LITE_1,gear_2_dir_pin]="PE3"; +PIN[B,AFC_LITE_1,gear_2_enable_pin]="PE0"; +PIN[B,AFC_LITE_1,gear_2_diag_pin]="PE4"; +PIN[B,AFC_LITE_1,gear_3_uart_pin]="PC6"; +PIN[B,AFC_LITE_1,gear_3_step_pin]="PD15"; +PIN[B,AFC_LITE_1,gear_3_dir_pin]="PD14"; +PIN[B,AFC_LITE_1,gear_3_enable_pin]="PC7"; +PIN[B,AFC_LITE_1,gear_3_diag_pin]="PC8"; +PIN[B,AFC_LITE_1,selector_uart_pin]=""; +PIN[B,AFC_LITE_1,selector_step_pin]=""; +PIN[B,AFC_LITE_1,selector_dir_pin]=""; +PIN[B,AFC_LITE_1,selector_enable_pin]=""; +PIN[B,AFC_LITE_1,selector_diag_pin]=""; +PIN[B,AFC_LITE_1,selector_endstop_pin]=""; +PIN[B,AFC_LITE_1,selector_servo_pin]=""; +PIN[B,AFC_LITE_1,encoder_pin]=""; +PIN[B,AFC_LITE_1,neopixel_pin]="PE14"; +PIN[B,AFC_LITE_1,gate_sensor_pin]="PC4"; +PIN[B,AFC_LITE_1,pre_gate_0_pin]="PC5"; +PIN[B,AFC_LITE_1,pre_gate_1_pin]="PB0"; +PIN[B,AFC_LITE_1,pre_gate_2_pin]="PB1"; +PIN[B,AFC_LITE_1,pre_gate_3_pin]="PB2"; +PIN[B,AFC_LITE_1,pre_gate_4_pin]=""; +PIN[B,AFC_LITE_1,pre_gate_5_pin]=""; +PIN[B,AFC_LITE_1,pre_gate_6_pin]=""; +PIN[B,AFC_LITE_1,pre_gate_7_pin]=""; +PIN[B,AFC_LITE_1,pre_gate_8_pin]=""; +PIN[B,AFC_LITE_1,pre_gate_9_pin]=""; +PIN[B,AFC_LITE_1,pre_gate_10_pin]=""; +PIN[B,AFC_LITE_1,pre_gate_11_pin]=""; +PIN[B,AFC_LITE_1,gear_sensor_0_pin]="PE8"; +PIN[B,AFC_LITE_1,gear_sensor_1_pin]="PE9"; +PIN[B,AFC_LITE_1,gear_sensor_2_pin]="PE10"; +PIN[B,AFC_LITE_1,gear_sensor_3_pin]="PE11"; +PIN[B,AFC_LITE_1,gear_sensor_4_pin]=""; +PIN[B,AFC_LITE_1,gear_sensor_5_pin]=""; +PIN[B,AFC_LITE_1,gear_sensor_6_pin]=""; +PIN[B,AFC_LITE_1,gear_sensor_7_pin]=""; +PIN[B,AFC_LITE_1,gear_sensor_8_pin]=""; +PIN[B,AFC_LITE_1,gear_sensor_9_pin]=""; +PIN[B,AFC_LITE_1,gear_sensor_10_pin]=""; +PIN[B,AFC_LITE_1,gear_sensor_11_pin]=""; +PIN[B,AFC_LITE_1,espooler_en_0_pin]="PA2"; +PIN[B,AFC_LITE_1,espooler_en_1_pin]="PA5"; +PIN[B,AFC_LITE_1,espooler_en_2_pin]="PB13"; +PIN[B,AFC_LITE_1,espooler_en_3_pin]="PD11"; +PIN[B,AFC_LITE_1,espooler_rwd_0_pin]="PA0"; +PIN[B,AFC_LITE_1,espooler_rwd_1_pin]="PA6"; +PIN[B,AFC_LITE_1,espooler_rwd_2_pin]="PB14"; +PIN[B,AFC_LITE_1,espooler_rwd_3_pin]="PD12"; +PIN[B,AFC_LITE_1,espooler_fwd_0_pin]="PA1"; +PIN[B,AFC_LITE_1,espooler_fwd_1_pin]="PA7"; +PIN[B,AFC_LITE_1,espooler_fwd_2_pin]="PB15"; +PIN[B,AFC_LITE_1,espooler_fwd_3_pin]="PD13"; + + +# +# Pins for BTT SKR Pico board +# Type-A MMU pin utilization +# +PIN[SKR_PICO_1,gear_uart_pin]="gpio9"; +PIN[SKR_PICO_1,gear_step_pin]="gpio11"; +PIN[SKR_PICO_1,gear_dir_pin]="gpio10"; +PIN[SKR_PICO_1,gear_enable_pin]="gpio12"; +PIN[SKR_PICO_1,gear_diag_pin]="gpio4"; +PIN[SKR_PICO_1,gear_1_step_pin]=""; +PIN[SKR_PICO_1,gear_1_dir_pin]=""; +PIN[SKR_PICO_1,gear_1_enable_pin]=""; +PIN[SKR_PICO_1,gear_1_diag_pin]=""; +PIN[SKR_PICO_1,gear_2_step_pin]=""; +PIN[SKR_PICO_1,gear_2_dir_pin]=""; +PIN[SKR_PICO_1,gear_2_enable_pin]=""; +PIN[SKR_PICO_1,gear_2_diag_pin]=""; +PIN[SKR_PICO_1,gear_3_step_pin]=""; +PIN[SKR_PICO_1,gear_3_dir_pin]=""; +PIN[SKR_PICO_1,gear_3_enable_pin]=""; +PIN[SKR_PICO_1,gear_3_diag_pin]=""; +PIN[SKR_PICO_1,selector_uart_pin]=""; +PIN[SKR_PICO_1,selector_step_pin]="gpio19"; +PIN[SKR_PICO_1,selector_dir_pin]="gpio28"; +PIN[SKR_PICO_1,selector_enable_pin]="gpio2"; +PIN[SKR_PICO_1,selector_diag_pin]="gpio25"; +PIN[SKR_PICO_1,selector_endstop_pin]=""; +PIN[SKR_PICO_1,selector_servo_pin]="gpio29"; +PIN[SKR_PICO_1,encoder_pin]=""; +PIN[SKR_PICO_1,neopixel_pin]="gpio24"; +PIN[SKR_PICO_1,gate_sensor_pin]=""; +PIN[SKR_PICO_1,pre_gate_0_pin]=""; +PIN[SKR_PICO_1,pre_gate_1_pin]=""; +PIN[SKR_PICO_1,pre_gate_2_pin]=""; +PIN[SKR_PICO_1,pre_gate_3_pin]=""; +PIN[SKR_PICO_1,pre_gate_4_pin]=""; +PIN[SKR_PICO_1,pre_gate_5_pin]=""; +PIN[SKR_PICO_1,pre_gate_6_pin]=""; +PIN[SKR_PICO_1,pre_gate_7_pin]=""; +PIN[SKR_PICO_1,pre_gate_8_pin]=""; +PIN[SKR_PICO_1,pre_gate_9_pin]=""; +PIN[SKR_PICO_1,pre_gate_10_pin]=""; +PIN[SKR_PICO_1,pre_gate_11_pin]=""; +PIN[SKR_PICO_1,gear_sensor_0_pin]=""; +PIN[SKR_PICO_1,gear_sensor_1_pin]=""; +PIN[SKR_PICO_1,gear_sensor_2_pin]=""; +PIN[SKR_PICO_1,gear_sensor_3_pin]=""; +PIN[SKR_PICO_1,gear_sensor_4_pin]=""; +PIN[SKR_PICO_1,gear_sensor_5_pin]=""; +PIN[SKR_PICO_1,gear_sensor_6_pin]=""; +PIN[SKR_PICO_1,gear_sensor_7_pin]=""; +PIN[SKR_PICO_1,gear_sensor_8_pin]=""; +PIN[SKR_PICO_1,gear_sensor_9_pin]=""; +PIN[SKR_PICO_1,gear_sensor_10_pin]=""; +PIN[SKR_PICO_1,gear_sensor_11_pin]=""; +PIN[SKR_PICO_1,espooler_en_0_pin]=""; +PIN[SKR_PICO_1,espooler_en_1_pin]=""; +PIN[SKR_PICO_1,espooler_en_2_pin]=""; +PIN[SKR_PICO_1,espooler_en_3_pin]=""; +PIN[SKR_PICO_1,espooler_rwd_0_pin]=""; +PIN[SKR_PICO_1,espooler_rwd_1_pin]=""; +PIN[SKR_PICO_1,espooler_rwd_2_pin]=""; +PIN[SKR_PICO_1,espooler_rwd_3_pin]=""; +PIN[SKR_PICO_1,espooler_fwd_0_pin]=""; +PIN[SKR_PICO_1,espooler_fwd_1_pin]=""; +PIN[SKR_PICO_1,espooler_fwd_2_pin]=""; +PIN[SKR_PICO_1,espooler_fwd_3_pin]=""; + + + +# +# Pins for BTT SKR Pico board +# Type-B MMU pin utilization +# +PIN[B,SKR_PICO_1,gear_uart_pin]="gpio9"; +PIN[B,SKR_PICO_1,gear_step_pin]="gpio11"; +PIN[B,SKR_PICO_1,gear_dir_pin]="gpio10"; +PIN[B,SKR_PICO_1,gear_enable_pin]="gpio12"; +PIN[B,SKR_PICO_1,gear_diag_pin]="gpio4"; +PIN[B,SKR_PICO_1,gear_1_step_pin]="gpio6"; +PIN[B,SKR_PICO_1,gear_1_dir_pin]="gpio5"; +PIN[B,SKR_PICO_1,gear_1_enable_pin]="gpio7"; +PIN[B,SKR_PICO_1,gear_1_diag_pin]="gpio3"; +PIN[B,SKR_PICO_1,gear_2_step_pin]="gpio19"; +PIN[B,SKR_PICO_1,gear_2_dir_pin]="gpio28"; +PIN[B,SKR_PICO_1,gear_2_enable_pin]="gpio2"; +PIN[B,SKR_PICO_1,gear_2_diag_pin]="gpio25"; +PIN[B,SKR_PICO_1,gear_3_step_pin]="gpio14"; +PIN[B,SKR_PICO_1,gear_3_dir_pin]="gpio13"; +PIN[B,SKR_PICO_1,gear_3_enable_pin]="gpio15"; +PIN[B,SKR_PICO_1,gear_3_diag_pin]="gpio16"; +PIN[B,SKR_PICO_1,selector_uart_pin]=""; +PIN[B,SKR_PICO_1,selector_step_pin]=""; +PIN[B,SKR_PICO_1,selector_dir_pin]=""; +PIN[B,SKR_PICO_1,selector_enable_pin]=""; +PIN[B,SKR_PICO_1,selector_diag_pin]=""; +PIN[B,SKR_PICO_1,selector_endstop_pin]=""; +PIN[B,SKR_PICO_1,selector_servo_pin]=""; +PIN[B,SKR_PICO_1,encoder_pin]=""; +PIN[B,SKR_PICO_1,neopixel_pin]="gpio24"; +PIN[B,SKR_PICO_1,gate_sensor_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_0_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_1_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_2_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_3_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_4_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_5_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_6_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_7_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_8_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_9_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_10_pin]=""; +PIN[B,SKR_PICO_1,pre_gate_11_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_0_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_1_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_2_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_3_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_4_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_5_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_6_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_7_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_8_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_9_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_10_pin]=""; +PIN[B,SKR_PICO_1,gear_sensor_11_pin]=""; +PIN[B,SKR_PICO_1,espooler_en_0_pin]=""; +PIN[B,SKR_PICO_1,espooler_en_1_pin]=""; +PIN[B,SKR_PICO_1,espooler_en_2_pin]=""; +PIN[B,SKR_PICO_1,espooler_en_3_pin]=""; +PIN[B,SKR_PICO_1,espooler_rwd_0_pin]=""; +PIN[B,SKR_PICO_1,espooler_rwd_1_pin]=""; +PIN[B,SKR_PICO_1,espooler_rwd_2_pin]=""; +PIN[B,SKR_PICO_1,espooler_rwd_3_pin]=""; +PIN[B,SKR_PICO_1,espooler_fwd_0_pin]=""; +PIN[B,SKR_PICO_1,espooler_fwd_1_pin]=""; +PIN[B,SKR_PICO_1,espooler_fwd_2_pin]=""; +PIN[B,SKR_PICO_1,espooler_fwd_3_pin]=""; + + + +# +# Pins for BTT EBB 42 CANbus v1.2 +# Type-A MMU pin utilization used on MMX and PicoMMU +# +PIN[EBB42_12,gear_uart_pin]="PA15"; +PIN[EBB42_12,gear_step_pin]="PD0"; +PIN[EBB42_12,gear_dir_pin]="PD1"; +PIN[EBB42_12,gear_enable_pin]="PD2"; +PIN[EBB42_12,gear_diag_pin]=""; +PIN[EBB42_12,gear_1_uart_pin]=""; +PIN[EBB42_12,gear_1_step_pin]=""; +PIN[EBB42_12,gear_1_dir_pin]=""; +PIN[EBB42_12,gear_1_enable_pin]=""; +PIN[EBB42_12,gear_1_diag_pin]=""; +PIN[EBB42_12,gear_2_uart_pin]=""; +PIN[EBB42_12,gear_2_step_pin]=""; +PIN[EBB42_12,gear_2_dir_pin]=""; +PIN[EBB42_12,gear_2_enable_pin]=""; +PIN[EBB42_12,gear_2_diag_pin]=""; +PIN[EBB42_12,gear_3_uart_pin]=""; +PIN[EBB42_12,gear_3_step_pin]=""; +PIN[EBB42_12,gear_3_dir_pin]=""; +PIN[EBB42_12,gear_3_enable_pin]=""; +PIN[EBB42_12,gear_3_diag_pin]=""; +PIN[EBB42_12,selector_uart_pin]=""; +PIN[EBB42_12,selector_step_pin]=""; +PIN[EBB42_12,selector_dir_pin]=""; +PIN[EBB42_12,selector_enable_pin]=""; +PIN[EBB42_12,selector_diag_pin]=""; +PIN[EBB42_12,selector_endstop_pin]=""; +PIN[EBB42_12,selector_servo_pin]="PB9"; +PIN[EBB42_12,encoder_pin]=""; +PIN[EBB42_12,neopixel_pin]="PD3"; +PIN[EBB42_12,gate_sensor_pin]="PB4"; +PIN[EBB42_12,pre_gate_0_pin]="PB7"; +PIN[EBB42_12,pre_gate_1_pin]="PB5"; +PIN[EBB42_12,pre_gate_2_pin]="PB6"; +PIN[EBB42_12,pre_gate_3_pin]="PB8"; +PIN[EBB42_12,pre_gate_4_pin]=""; +PIN[EBB42_12,pre_gate_5_pin]=""; +PIN[EBB42_12,pre_gate_6_pin]=""; +PIN[EBB42_12,pre_gate_7_pin]=""; +PIN[EBB42_12,pre_gate_8_pin]=""; +PIN[EBB42_12,pre_gate_9_pin]=""; +PIN[EBB42_12,pre_gate_10_pin]=""; +PIN[EBB42_12,pre_gate_11_pin]=""; +PIN[EBB42_12,gear_sensor_0_pin]=""; +PIN[EBB42_12,gear_sensor_1_pin]=""; +PIN[EBB42_12,gear_sensor_2_pin]=""; +PIN[EBB42_12,gear_sensor_3_pin]=""; +PIN[EBB42_12,gear_sensor_4_pin]=""; +PIN[EBB42_12,gear_sensor_5_pin]=""; +PIN[EBB42_12,gear_sensor_6_pin]=""; +PIN[EBB42_12,gear_sensor_7_pin]=""; +PIN[EBB42_12,gear_sensor_8_pin]=""; +PIN[EBB42_12,gear_sensor_9_pin]=""; +PIN[EBB42_12,gear_sensor_10_pin]=""; +PIN[EBB42_12,gear_sensor_11_pin]=""; +PIN[EBB42_12,espooler_en_0_pin]=""; +PIN[EBB42_12,espooler_en_1_pin]=""; +PIN[EBB42_12,espooler_en_2_pin]=""; +PIN[EBB42_12,espooler_en_3_pin]=""; +PIN[EBB42_12,espooler_rwd_0_pin]=""; +PIN[EBB42_12,espooler_rwd_1_pin]=""; +PIN[EBB42_12,espooler_rwd_2_pin]=""; +PIN[EBB42_12,espooler_rwd_3_pin]=""; +PIN[EBB42_12,espooler_fwd_0_pin]=""; +PIN[EBB42_12,espooler_fwd_1_pin]=""; +PIN[EBB42_12,espooler_fwd_2_pin]=""; +PIN[EBB42_12,espooler_fwd_3_pin]=""; + + + +# Pins for WGB board (AFC or other type-B designs) +# Type-B MMU pin utilization +# +PIN[B,WGB_3,gear_uart_pin]="PC12"; +PIN[B,WGB_3,gear_step_pin]="PD4"; +PIN[B,WGB_3,gear_dir_pin]="PD3"; +PIN[B,WGB_3,gear_enable_pin]="PD5"; +PIN[B,WGB_3,gear_diag_pin]="PD6"; +PIN[B,WGB_3,gear_1_uart_pin]="PC10"; +PIN[B,WGB_3,gear_1_step_pin]="PD0"; +PIN[B,WGB_3,gear_1_dir_pin]="PC11"; +PIN[B,WGB_3,gear_1_enable_pin]="PD1"; +PIN[B,WGB_3,gear_1_diag_pin]="PD2"; +PIN[B,WGB_3,gear_2_uart_pin]="PA9"; +PIN[B,WGB_3,gear_2_step_pin]="PC8"; +PIN[B,WGB_3,gear_2_dir_pin]="PA10"; +PIN[B,WGB_3,gear_2_enable_pin]="PC9"; +PIN[B,WGB_3,gear_2_diag_pin]="PA8"; +PIN[B,WGB_3,gear_3_uart_pin]="PC6"; +PIN[B,WGB_3,gear_3_step_pin]="PD13"; +PIN[B,WGB_3,gear_3_dir_pin]="PD12"; +PIN[B,WGB_3,gear_3_enable_pin]="PD14"; +PIN[B,WGB_3,gear_3_diag_pin]="PD15"; +PIN[B,WGB_3,selector_uart_pin]=""; +PIN[B,WGB_3,selector_step_pin]=""; +PIN[B,WGB_3,selector_dir_pin]=""; +PIN[B,WGB_3,selector_enable_pin]=""; +PIN[B,WGB_3,selector_diag_pin]=""; +PIN[B,WGB_3,selector_endstop_pin]=""; +PIN[B,WGB_3,selector_servo_pin]=""; +PIN[B,WGB_3,encoder_pin]=""; +PIN[B,WGB_3,neopixel_pin]="PB15"; +PIN[B,WGB_3,gate_sensor_pin]="PE7"; +PIN[B,WGB_3,pre_gate_0_pin]="PA1"; +PIN[B,WGB_3,pre_gate_1_pin]="PA2"; +PIN[B,WGB_3,pre_gate_2_pin]="PA3"; +PIN[B,WGB_3,pre_gate_3_pin]="PA4"; +PIN[B,WGB_3,pre_gate_4_pin]=""; +PIN[B,WGB_3,pre_gate_5_pin]=""; +PIN[B,WGB_3,pre_gate_6_pin]=""; +PIN[B,WGB_3,pre_gate_7_pin]=""; +PIN[B,WGB_3,pre_gate_8_pin]=""; +PIN[B,WGB_3,pre_gate_9_pin]=""; +PIN[B,WGB_3,pre_gate_10_pin]=""; +PIN[B,WGB_3,pre_gate_11_pin]=""; +PIN[B,WGB_3,gear_sensor_0_pin]="PA5"; +PIN[B,WGB_3,gear_sensor_1_pin]="PA6"; +PIN[B,WGB_3,gear_sensor_2_pin]="PA7"; +PIN[B,WGB_3,gear_sensor_3_pin]="PC4"; +PIN[B,WGB_3,gear_sensor_4_pin]=""; +PIN[B,WGB_3,gear_sensor_5_pin]=""; +PIN[B,WGB_3,gear_sensor_6_pin]=""; +PIN[B,WGB_3,gear_sensor_7_pin]=""; +PIN[B,WGB_3,gear_sensor_8_pin]=""; +PIN[B,WGB_3,gear_sensor_9_pin]=""; +PIN[B,WGB_3,gear_sensor_10_pin]=""; +PIN[B,WGB_3,gear_sensor_11_pin]=""; +PIN[B,WGB_3,espooler_en_0_pin]="PD11"; +PIN[B,WGB_3,espooler_en_1_pin]="PD10"; +PIN[B,WGB_3,espooler_en_2_pin]="PD9"; +PIN[B,WGB_3,espooler_en_3_pin]="PD8"; +PIN[B,WGB_3,espooler_rwd_0_pin]="PE15"; +PIN[B,WGB_3,espooler_rwd_1_pin]="PE13"; +PIN[B,WGB_3,espooler_rwd_2_pin]="PE11"; +PIN[B,WGB_3,espooler_rwd_3_pin]="PE8"; +PIN[B,WGB_3,espooler_fwd_0_pin]="PB10"; +PIN[B,WGB_3,espooler_fwd_1_pin]="PE14"; +PIN[B,WGB_3,espooler_fwd_2_pin]="PE12"; +PIN[B,WGB_3,espooler_fwd_3_pin]="PE10"; + + + +# Pins for TZB V1 board +# Type-A MMU pin utilization +# +PIN[TZB_1,gear_uart_pin]="PD1"; +PIN[TZB_1,gear_step_pin]="PD0"; +PIN[TZB_1,gear_dir_pin]="PA15"; +PIN[TZB_1,gear_enable_pin]="PD3"; +PIN[TZB_1,gear_diag_pin]="PD2"; +PIN[TZB_1,gear_1_uart_pin]=""; +PIN[TZB_1,gear_1_step_pin]=""; +PIN[TZB_1,gear_1_dir_pin]=""; +PIN[TZB_1,gear_1_enable_pin]=""; +PIN[TZB_1,gear_1_diag_pin]=""; +PIN[TZB_1,gear_2_uart_pin]=""; +PIN[TZB_1,gear_2_step_pin]=""; +PIN[TZB_1,gear_2_dir_pin]=""; +PIN[TZB_1,gear_2_enable_pin]=""; +PIN[TZB_1,gear_2_diag_pin]=""; +PIN[TZB_1,gear_3_uart_pin]=""; +PIN[TZB_1,gear_3_step_pin]=""; +PIN[TZB_1,gear_3_dir_pin]=""; +PIN[TZB_1,gear_3_enable_pin]=""; +PIN[TZB_1,gear_3_diag_pin]=""; +PIN[TZB_1,selector_uart_pin]="PB5"; +PIN[TZB_1,selector_step_pin]="PB4"; +PIN[TZB_1,selector_dir_pin]="PB3"; +PIN[TZB_1,selector_enable_pin]="PB7"; +PIN[TZB_1,selector_diag_pin]="PB6"; +PIN[TZB_1,selector_endstop_pin]="PA4"; +PIN[TZB_1,selector_servo_pin]="PA0"; +PIN[TZB_1,encoder_pin]="PA2"; +PIN[TZB_1,neopixel_pin]="PA5"; +PIN[TZB_1,gate_sensor_pin]=""; +PIN[TZB_1,pre_gate_0_pin]="PB14"; +PIN[TZB_1,pre_gate_1_pin]="PB13"; +PIN[TZB_1,pre_gate_2_pin]="PB12"; +PIN[TZB_1,pre_gate_3_pin]="PA7"; +PIN[TZB_1,pre_gate_4_pin]="PA6"; +PIN[TZB_1,pre_gate_5_pin]="PB2"; +PIN[TZB_1,pre_gate_6_pin]="PA13"; +PIN[TZB_1,pre_gate_7_pin]="PA10"; +PIN[TZB_1,pre_gate_8_pin]=""; +PIN[TZB_1,pre_gate_9_pin]=""; +PIN[TZB_1,pre_gate_10_pin]=""; +PIN[TZB_1,pre_gate_11_pin]=""; +PIN[TZB_1,gear_sensor_0_pin]=""; +PIN[TZB_1,gear_sensor_1_pin]=""; +PIN[TZB_1,gear_sensor_2_pin]=""; +PIN[TZB_1,gear_sensor_3_pin]=""; +PIN[TZB_1,gear_sensor_4_pin]=""; +PIN[TZB_1,gear_sensor_5_pin]=""; +PIN[TZB_1,gear_sensor_6_pin]=""; +PIN[TZB_1,gear_sensor_7_pin]=""; +PIN[TZB_1,gear_sensor_8_pin]=""; +PIN[TZB_1,gear_sensor_9_pin]=""; +PIN[TZB_1,gear_sensor_10_pin]=""; +PIN[TZB_1,gear_sensor_11_pin]=""; diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/components/__init__.py b/test/components/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/components/test_mmu_server.py b/test/components/test_mmu_server.py new file mode 100644 index 000000000000..cd7ae297d273 --- /dev/null +++ b/test/components/test_mmu_server.py @@ -0,0 +1,88 @@ +import os +import shutil +import unittest +from unittest.mock import MagicMock + +from components.mmu_server import MmuServer + +class TestMmuServerFileProcessor(unittest.TestCase): + TOOLCHANGE_FILEPATH = 'test/support/toolchange.gcode' + NO_TOOLCHANGE_FILEPATH = 'test/support/no_toolchange.gcode' + + def setUp(self): + self.subject = MmuServer(MagicMock()) + shutil.copyfile('test/support/toolchange.orig.gcode', self.TOOLCHANGE_FILEPATH) + shutil.copyfile('test/support/no_toolchange.orig.gcode', self.NO_TOOLCHANGE_FILEPATH) + + def tearDown(self): + os.remove(self.TOOLCHANGE_FILEPATH) + os.remove(self.NO_TOOLCHANGE_FILEPATH) + + def test_filelist_callback_when_enabled(self): + self.subject.enable_file_preprocessor = True + self.subject._write_mmu_metadata = MagicMock() + + self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.gcode'}}) + + self.subject._write_mmu_metadata.assert_called_once() + + def test_filelist_callback_when_disabled(self): + self.subject.enable_file_preprocessor = False + self.subject._write_mmu_metadata = MagicMock() + + self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.gcode'}}) + + self.subject._write_mmu_metadata.assert_not_called() + + def test_filelist_callback_when_wrong_event(self): + self.subject.enable_file_preprocessor = False + self.subject._write_mmu_metadata = MagicMock() + + self.subject._filelist_changed({'action': 'move_file', 'item': {'path': 'test.gcode'}}) + + self.subject._write_mmu_metadata.assert_not_called() + + def test_filelist_callback_when_wrong_file_type(self): + self.subject.enable_file_preprocessor = False + self.subject._write_mmu_metadata = MagicMock() + + self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.txt'}}) + + self.subject._write_mmu_metadata.assert_not_called() + + def test_write_mmu_metadata_when_writing_to_files(self): + self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) + + with open(self.TOOLCHANGE_FILEPATH, 'r') as f: + file_contents = f.read() + self.assertIn('PRINT_START MMU_TOOLS_USED=0,1,3,4,5,12\n', file_contents) + + def test_write_mmu_metadata_when_no_toolchanges(self): + self.subject._write_mmu_metadata(self.NO_TOOLCHANGE_FILEPATH) + + with open(self.NO_TOOLCHANGE_FILEPATH, 'r') as f: + file_contents = f.read() + self.assertIn('PRINT_START MMU_TOOLS_USED=\n', file_contents) + + def test_write_mmu_metadata_does_not_replace_comments(self): + self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) + + with open(self.TOOLCHANGE_FILEPATH, 'r') as f: + file_contents = f.read() + self.assertIn('; start_gcode: PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools!', file_contents) + + def test_inject_tool_usage_called_if_placeholder(self): + self.subject._inject_tool_usage = MagicMock() + + self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) + + self.subject._inject_tool_usage.assert_called() + + def test_inject_tool_usage_not_called_if_no_placeholder(self): + # Call it once to remove the placeholder + self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) + self.subject._inject_tool_usage = MagicMock() + + self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) + + self.subject._inject_tool_usage.assert_not_called() diff --git a/test/runner.sh b/test/runner.sh new file mode 100755 index 000000000000..5dcd32983583 --- /dev/null +++ b/test/runner.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Happy Hare MMU Software +# Test runner +# +# Copyright (C) 2023 Kieran Eglin <@kierantheman (discord)>, +# +# NOTE: in order for tests to get picked up automatically, you must do the following: +# 1. Create a file in the test directory with the name test_*.py +# 2. Create a class in that file that inherits from unittest.TestCase +# 3. Ensure that each test directory has a blank file named `__init__.py` + +python3 -m unittest diff --git a/test/support/no_toolchange.orig.gcode b/test/support/no_toolchange.orig.gcode new file mode 100644 index 000000000000..87b03d9e4c31 --- /dev/null +++ b/test/support/no_toolchange.orig.gcode @@ -0,0 +1,21 @@ +PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! +G1 F1200 +G1 X167.759 Y180.16 E.00802 +G1 X167.263 Y180.262 E.02305 +G1 X166.979 Y180.193 E.0133 +G1 X166.433 Y179.911 E.02797 +G1 X165.996 Y179.509 E.02703 +G1 X165.321 Y178.61 E.05117 +G1 X164.865 Y178.289 E.02538 +G1 X164.172 Y177.944 E.03524 +G1 X163.551 Y177.578 E.03281 +G1 X162.963 Y177.124 E.03382 +G1 X162.489 Y176.633 E.03107 +G1 X162.239 Y176.218 E.02205 +G1 X162.031 Y175.724 E.0244 +G1 X162.025 Y174.997 E.03309 +G1 X162.042 Y174.657 E.0155 +G1 X162.144 Y174.141 E.02394 +G1 X162.313 Y174.145 E.0077 +G1 X162.633 Y174.049 E.01521 +G1 X162.964 Y174.006 E.01519 diff --git a/test/support/toolchange.orig.gcode b/test/support/toolchange.orig.gcode new file mode 100644 index 000000000000..0e822ae6eddf --- /dev/null +++ b/test/support/toolchange.orig.gcode @@ -0,0 +1,33 @@ +PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! +T0 +G1 F1200 +G1 X167.759 Y180.16 E.00802 +G1 X167.263 Y180.262 E.02305 +G1 X166.979 Y180.193 E.0133 +T1 +G1 X166.433 Y179.911 E.02797 +G1 X165.996 Y179.509 E.02703 +G1 X165.321 Y178.61 E.05117 +G1 X164.865 Y178.289 E.02538 +G1 X164.172 Y177.944 E.03524 +G1 X163.551 Y177.578 E.03281 + +; T7 +; The above shouldn't count +G1 X162.963 Y177.124 E.03382 +G1 X162.489 Y176.633 E.03107 +T12 ; Testing 2-digit numbers +G1 X162.239 Y176.218 E.02205 +G1 X162.031 Y175.724 E.0244 +MMU_CHANGE_TOOL TOOL=3 +MMU_CHANGE_TOOL FOO=bar TOOL=4 BAZ=quz +G1 X162.025 Y174.997 E.03309 +G1 X162.042 Y174.657 E.0155 +MMU_CHANGE_TOOL_STANDALONE TOOL=5 +G1 X162.144 Y174.141 E.02394 +SHOULDNT_COUNT ARG=T7 +G1 X162.313 Y174.145 E.0077 +G1 X162.633 Y174.049 E.01521 +G1 X162.964 Y174.006 E.01519 +; simulating slicer metadata below (should not be replaced) +; start_gcode: PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! diff --git a/utils/plot_sync_feedback.sh b/utils/plot_sync_feedback.sh new file mode 100755 index 000000000000..1ad59c39abd2 --- /dev/null +++ b/utils/plot_sync_feedback.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Simple wrapper to source klipper python env and run sync_feedback.py with the proper PYTHONPATH + +# ----- Argument validation ----- +[ "$#" -gt 1 ] && { + echo "Usage: $0 []" + exit 1 +} + +log="${1:-`ls -1 ~/printer_data/logs/sync_?.jsonl 2>/dev/null`}" + +if ! [ -e "$log" ]; then + echo $log Flowguard telemetry file doesn\'t exist + exit 1 +fi + +echo Processing ${log} Flowguard telemetry file + +source ~/klippy-env/bin/activate +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MMU_DIR="${SCRIPT_DIR}/../extras/mmu" +export PYTHONPATH="${MMU_DIR}:${PYTHONPATH}" + +python "${SCRIPT_DIR}/sync_feedback_sim.py" --plot "$log" diff --git a/utils/sim_sync_feedback.sh b/utils/sim_sync_feedback.sh new file mode 100755 index 000000000000..34a0499c8da0 --- /dev/null +++ b/utils/sim_sync_feedback.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Simple wrapper to run sync_feedback.py with the proper PYTHONPATH +# +# Simulation cmd line options: +# --sensor-type=[P|D|CO|TO] +# --buffer-range-mm= (default=8.0) +# --buffer-max-range-mm (default=12.0) +# --initial-sensor=[random|neutral] +# --stride-mm=10 (normal extruder movement between updates) +# --tick-dt-s (default dt used only for manual 'tick', 'clog' and 'tangle', default: 1.0) +# --rd-start (starting extruder rotation distance, default: 20.0) +# --sensor-lag-mm (lag in sensor reacting to movement, default: 0) +# --chaos=2 (simulates friction and jerky movements, multiple of buffer_max_range) +# --sample-error=0.25 (simulates "late" updates from extruder movement Eg 0.25 = 100%-125% of stride) +# --switch-hysteresis=0.2 (factor based on buffer_range) +# --use-twolevel (forces P type sensors to operation in twolevel mode instead of EKF default) +# --log-debug (display debug trace log entries) +# --out= (output PNG filename for plots, default: sim_plot.png) +# --log= (simulator json log output, default: sim.jsonl) +# Use --chaos=0 sample-error=0 for "pure" simulation +# +# E.g. realistic type-P proportional sensor simulation: +# ./sim_sync_feedback.sh --sensor-type P --initial-sensor=random --stride-mm=2.5 --chaos=2 --sample-error=0.5 --sensor-lag=0 +# +# E.g. realistic type-CO switch sensor simulation: +# ./sim_sync_feedback.sh --sensor-type CO --initial-sensor=random --stride-mm=2.5 --chaos=2 --sample-error=0.5 --sensor-lag=0 --switch-hysteresis=0.2 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MMU_DIR="${SCRIPT_DIR}/../extras/mmu" +export PYTHONPATH="${MMU_DIR}:${PYTHONPATH}" + +python "${SCRIPT_DIR}/sync_feedback_sim.py" "$@" + diff --git a/utils/sync_feedback_sim.py b/utils/sync_feedback_sim.py new file mode 100644 index 000000000000..7d9d065f24f6 --- /dev/null +++ b/utils/sync_feedback_sim.py @@ -0,0 +1,2027 @@ +# -*- coding: utf-8 -*- +# +# Happy Hare MMU Software +# Simulator/CLI for the (movement-based) filament tension controller +# +# Simulator invocation: +# python -m utils.sync_feedback.py +# --sensor-type=[P|D|CO|TO] +# --buffer-range-mm= (default=8.0) +# --buffer-max-range-mm (default=12.0) +# --initial-sensor=[random|neutral] +# --stride-mm=10 (normal extruder movement between updates) +# --tick-dt-s (default dt used only for manual 'tick', 'clog' and 'tangle', default: 1.0) +# --rd-start (starting extruder rotation distance, default: 20.0) +# --sensor-lag-mm (lag in sensor reacting to movement, default: 0) +# --chaos=2 (simulates friction and jerky movements, multiple of buffer_max_range) +# --sample-error=0.25 (simulates "late" updates from extruder movement Eg 0.25 = 100%-125% of stride) +# --switch-hysteresis=0.2 (factor based on buffer_range) +# --use-twolevel (forces P type sensors to operation in twolevel mode instead of EKF default) +# --log-debug (display debug trace log entries) +# --out= (output PNG filename for plots, default: sim_plot.png) +# --log= (simulator json log output, default: sim.jsonl) +# Use --chaos=0 sample-error=0 for "pure" simulation +# +# Grpahing logs: +# python sync_feedback.py --plot= +# +# +# Requires: mmu_sync_feedback_manager.py (SyncControllerConfig, SyncController) +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +from __future__ import annotations +import argparse +import json +import math +import os +import time +import random +from typing import Any, Dict, List, Optional, Tuple + +import matplotlib.pyplot as plt +import numpy as np + +from mmu_sync_controller import SyncControllerConfig, SyncController +#try: +# from mmu_sync_controller import SyncControllerConfig, SyncController +#except Exception as e: +# print("Could not load SyncConfig module. Simulation not possible.") + +# ---------- Optional readline for command history (Up/Down arrows) ---------- +HAVE_READLINE = False +try: + import readline # POSIX / macOS + HAVE_READLINE = True +except Exception: + try: + import pyreadline as readline # legacy Windows + HAVE_READLINE = True + except Exception: + readline = None # plain input() fallback + +def _setup_readline(history_limit: int = 500): + if not HAVE_READLINE: + return + try: + readline.set_history_length(history_limit) + try: + readline.parse_and_bind("tab: complete") + except Exception: + pass + except Exception: + pass + +def _add_history(line: str): + if not HAVE_READLINE: + return + try: + n = readline.get_current_history_length() + if n == 0 or readline.get_history_item(n) != line: + readline.add_history(line) + except Exception: + pass + +# ------------------------------ JSON Logger ----------------------------- + +class SimLogger: + """Append-only JSONL logger that clears on startup; tick starts at 0.""" + def __init__(self, path: str = "sim.jsonl", truncate_on_init: bool = True): + self.path = os.path.abspath(path) + self.records_in_session: List[Dict[str, Any]] = [] + if truncate_on_init: + self.clear() + else: + self.tick = self._load_last_tick_plus_one() + + def clear(self): + with open(self.path, "w", encoding="utf-8"): + pass + self.tick = 0 + self.records_in_session.clear() + + def _load_last_tick_plus_one(self) -> int: + last_tick = -1 + if os.path.exists(self.path): + try: + with open(self.path, "r", encoding="utf-8") as f: + for line in f: + try: + rec = json.loads(line) + if isinstance(rec, dict): + t = rec.get("meta", {}).get("log_tick", None) + if t is not None: + last_tick = max(last_tick, int(t)) + except Exception: + continue + except Exception: + pass + return last_tick + 1 + + def append(self, record: Dict[str, Any]) -> Dict[str, Any]: + rec = dict(record) + rec.setdefault("meta", {}) + rec["meta"]["log_tick"] = self.tick + with open(self.path, "a", encoding="utf-8") as f: + f.write(json.dumps(rec) + "\n") + self.records_in_session.append(rec) + self.tick += 1 + return rec + + def write_header(self, header: Dict[str, Any]) -> None: + try: + with open(self.path, "a", encoding="utf-8") as f: + f.write(json.dumps({"header": header}) + "\n") + except Exception: + pass + + def load_all(self) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + if not os.path.exists(self.path): + return out + with open(self.path, "r", encoding="utf-8") as f: + for line in f: + try: + out.append(json.loads(line)) + except Exception: + continue + return out + +# -------------------------- Printer (physical model) -------------------- + +class SimplePrinterModel: + """ + Printer model (normalized spring x_true; spring_mm = x_true * (buffer_range_mm/2)): + + x_true[k+1] = x_true[k] + (2 / buffer_range_mm) * Δ_rel_true + + where the *relative* motion between the filament and the gear during an + extruder command d_ext is: + + Δ_rel_true = d_ext * (extruder_rd_true / rd_prev - 1.0) + + Notes: + • If rd_true == rd_prev → Δ_rel_true = 0 (spring stays flat). + • If rd_true > rd_prev → (ratio - 1) > 0: + - d_ext > 0 (extrude) → compression (+x / +spring) + - d_ext < 0 (retract) → tension (−x / −spring) + • rd_prev must be the RD in effect *for this step* (before it changes). + """ + def __init__( + self, + controller: SyncController, + extruder_rd_true: Optional[float] = None, + initial_spring_mm: float = 0.0, + chaos: float = 0.0, + hysteresis: float = 0.0, + ): + self.ctrl = controller + self.buffer_range_mm = controller.cfg.buffer_range_mm + self.buffer_max_range_mm = controller.cfg.buffer_max_range_mm + + # Normalization factors / limits + self.K = 2.0 / self.buffer_range_mm # mm_rel -> normalized x + self._norm_clip = max(1e-9, self.buffer_max_range_mm / self.buffer_range_mm) + + # State: true normalized spring position and measured proxy + x0 = (2.0 * initial_spring_mm) / self.buffer_range_mm + self.x_true = max(-self._norm_clip, min(self._norm_clip, x0)) + rd_ref = controller.cfg.rd_start + self.extruder_rd_true = extruder_rd_true if extruder_rd_true is not None else rd_ref + + self.chaos = max(0.0, min(2.0, float(chaos))) + self.x_meas = self.x_true + + # Hysteresis fraction for switch sensors (0..0.4 typical) + self.hysteresis = max(0.0, min(0.4, float(hysteresis))) + + # Last digital switch state (for D/CO/TO). Initialize from current x_meas. + self._last_switch_value: int = 0 + self._bootstrap_switch_state() + + # Monotonic simulation clock (seconds since start). Commands must use/advance this. + self.time_s: float = 0.0 + + # ---------- Small physics helpers (new) ---------- + def ratio_true_over_prev(self, rd_prev: float) -> float: + """Return RD_true / rd_prev with a tiny floor to avoid division by ~0.""" + return self.extruder_rd_true / max(1e-9, float(rd_prev)) + + def delta_rel(self, rd_prev: float, d_ext_mm: float) -> float: + """ + Relative filament-vs-gear motion (mm) for this chunk, using the *current* hardware RD + and the controller RD *in effect* during this chunk (rd_prev). + """ + ratio = self.ratio_true_over_prev(rd_prev) # RD_true / RD_prev + return float(d_ext_mm) * (ratio - 1.0) + + def gain_per_mm(self, rd_in_effect: float) -> float: + """ + Normalized spring change per +1 mm of extruder motion for the *current* tick. + g = (2 / buffer_range_mm) * (rd_true / rd_in_effect - 1.0) + Positive g means +x (compression) for +d_ext when rd_true > rd_in_effect. + """ + ratio = self.extruder_rd_true / max(1e-9, float(rd_in_effect)) + return (2.0 / self.buffer_range_mm) * (ratio - 1.0) + + def advance_physics(self, rd_in_effect: float, d_ext_mm: float) -> None: + """ + Advance the physical spring using the RD that was in effect during this motion. + Δx = g * d_ext_mm (with g from gain_per_mm) + """ + self.x_true += self.gain_per_mm(rd_in_effect) * float(d_ext_mm) + self.x_true = min(max(self.x_true, -self._norm_clip), self._norm_clip) # clip to physical limits + + def apply_motion(self, d_ext: float, rd_used: float) -> float: + """ + Advance the physical spring for a single commanded extruder motion (in mm), + using the rotation distance that was actually in effect for that motion. + + Physics (normalized spring x_true; spring_mm = x_true * (buffer_range_mm/2)): + gear_mm = (extruder_rd_true / rd_used) * d_ext + Δ_rel = gear_mm - d_ext # positive = compression + x_true += (2/BR) * Δ_rel + + Returns the Δ_rel (mm) applied this step (useful for debugging). + """ + rd_used = max(1e-9, float(rd_used)) + ratio = self.extruder_rd_true / rd_used + delta_rel = (ratio * d_ext) - d_ext # = d_ext * (ratio - 1) + self.x_true += self.K * delta_rel + self.x_true = min(max(self.x_true, -self._norm_clip), self._norm_clip) + return delta_rel + + # ---------- Basic properties ---------- + def set_extruder_rd_true(self, rd_true: float): + self.extruder_rd_true = float(rd_true) + + def spring_mm(self) -> float: + return self.x_true * (self.buffer_range_mm / 2.0) + + # ---------- Simulation clock ---------- + def get_time_s(self) -> float: + """Return the current absolute simulation time (seconds, monotonic).""" + return float(self.time_s) + + def advance_time(self, dt_s: float) -> float: + """Advance the internal clock by a non-negative dt (seconds) and return new time.""" + try: + dt = max(0.0, float(dt_s)) + except Exception: + dt = 0.0 + self.time_s += dt + return self.time_s + + def reset_time(self, to: float = 0.0) -> None: + """Reset the internal clock to a non-negative value (default 0).""" + self.time_s = max(0.0, float(to)) + + # ---------- Sensor modeling ---------- + def get_switch_thresholds(self) -> Tuple[float, float]: + """ + Returns (thr_on, thr_off) in normalized units. + thr_on = magnitude to TRIGGER (go from 0 to active state) + thr_off = magnitude to UNTRIGGER (leave the active state) + """ + thr_mid = float(self.ctrl.cfg.flowguard_extreme_threshold) + thr_on = min(self._norm_clip, thr_mid * (1.0 + self.hysteresis)) + thr_off = max(0.0, min(thr_on, thr_mid * (1.0 - self.hysteresis))) + return (thr_on, thr_off) + + def _bootstrap_switch_state(self) -> None: + """Initialize last switch value from current x_meas against midpoint thresholds.""" + st = self.ctrl.cfg.sensor_type + thr = float(self.ctrl.cfg.flowguard_extreme_threshold) + if st == "D": + if self.x_meas >= thr: + self._last_switch_value = 1 + elif self.x_meas <= -thr: + self._last_switch_value = -1 + else: + self._last_switch_value = 0 + elif st == "CO": + self._last_switch_value = 1 if self.x_meas >= thr else 0 + elif st == "TO": + self._last_switch_value = -1 if self.x_meas <= -thr else 0 + else: + self._last_switch_value = 0 + + def measure(self) -> float | int: + """ + Return a sensor reading (float for 'P', int for 'D'/'CO'/'TO'), + optionally with stick–slip style lag ('chaos'). + """ + if self.chaos <= 1e-12: + self.x_meas = self.x_true + else: + # Move measured position toward true with a random "jerk". + jerk_mm_max = self.chaos * self.buffer_max_range_mm + draw_mm = random.random() * jerk_mm_max + if draw_mm >= (self.buffer_max_range_mm - 1e-12): + self.x_meas = self.x_true + else: + gap_norm = self.x_true - self.x_meas + gap_mm = abs(gap_norm) * (self.buffer_range_mm / 2.0) + if gap_mm > 1e-12: + move_mm = min(draw_mm, gap_mm) + move_norm = (2.0 / self.buffer_range_mm) * move_mm + self.x_meas += math.copysign(move_norm, gap_norm) + self.x_meas = min(max(self.x_meas, -self._norm_clip), self._norm_clip) + + if self.ctrl.cfg.sensor_type == "P": + return max(-1.0, min(1.0, self.x_meas)) + + # Switch sensors with hysteresis + thr_on, thr_off = self.get_switch_thresholds() + st = self.ctrl.cfg.sensor_type + x = self.x_meas + s_prev = self._last_switch_value + s_new = s_prev + + if st == "D": + # 3-state: -1, 0, +1 + if s_prev == 0: + if x >= +thr_on: + s_new = 1 + elif x <= -thr_on: + s_new = -1 + elif s_prev == 1: + if x <= +thr_off: + s_new = 0 + elif s_prev == -1: + if x >= -thr_off: + s_new = 0 + + elif st == "CO": + # 0 → 1 at +thr_on; 1 → 0 at +thr_off + if s_prev == 0: + if x >= +thr_on: + s_new = 1 + else: # s_prev == 1 + if x <= +thr_off: + s_new = 0 + + elif st == "TO": + # 0 → -1 at -thr_on; -1 → 0 at -thr_off + if s_prev == 0: + if x <= -thr_on: + s_new = -1 + else: # s_prev == -1 + if x >= -thr_off: + s_new = 0 + + self._last_switch_value = s_new + return s_new + +# ------------------------------- Plotting ------------------------------- + +def plot_progress( + records: List[Dict[str, Any]], + out_path: Optional[str] = None, + dt_s: Optional[float] = None, + sensor_label: Optional[str] = None, # "D", "CO", "TO" or "P" + stop_on_fg_trip: bool = False, + rd_start: Optional[float] = None, + show_rd_true: bool = True, + show_ticks: bool = False, + show_mm_axis: bool = True, + show_tick_times: bool = True, + mm_axis_mode: str = "abs", # "abs" or "signed" + summary_txt: str = "", + title_txt: str = "Filament Sync Simulation", +): + """ + Plot RD (left axis), sensor reading/UI (right axis), Bowden/Buffer spring (2nd right), and autotune-recommendation markers. + """ + if not records: + raise ValueError("No records to plot.") + + RD_COLOR = "#0000ff80" # "tab:blue" alpha=0.5 + RD_TRUE_COLOR = "#4c4c4c20" # grey 0.3 alpha=0.125 + RD_REF_COLOR = "#00800080" # green + SENSOR_COLOR = "#ff7f0e80" # "tab:orange" alpha=0.5 + SENSOR_UI_COLOR = "tab:brown" + SPRING_COLOR = "#ff000080" # red alpha=0.5 + C_EST_COLOR = "#30303080" # grey alpha=0.5 + X_EST_COLOR = "#bcbd2280" # "tab:olive" alpha=0.5 + AUTOTUNE_COLOR = "#2ca02cff" # "tab:green" alpha=1.0 + TICK_MARK_COLOR = "#40404080" # grey alpha=0.5 + HEADROOM_COLOR = "#e377c280" # "tab:pink" alpha=0.5 + LOWLIGHT_TEXT = "0.5" # grey + + # On RD axis + BOLD_LW = 3.0 + MAIN_LW = 2.0 + DEBUG_LW = 1.0 + SECONDARY_LW = 1.0 + + HEADER_ZORDER = 999 + TRIPBOX_ZORDER = 1000 + AUTOTUNE_ZORDER = 1001 + + # Prefer absolute t_s if present, else reconstruct from dt_s grid + have_ts = all(("meta" in r) and ("t_s" in r["meta"]) for r in records) + if have_ts: + t_axis = [float(r["meta"]["t_s"]) for r in records] + else: + if dt_s is None: + dt_s = records[0].get("meta", {}).get("dt_s", 1.0) + t_axis = [] + t_acc = 0.0 + for r in records: + step_dt = r.get("meta", {}).get("dt_s") + if step_dt is None: + step_dt = dt_s + t_axis.append(t_acc) + t_acc += float(step_dt) + + # Flowguard + clog = [ (str(r["output"]["flowguard"].get("trigger","")) == "clog") for r in records ] + tangle = [ (str(r["output"]["flowguard"].get("trigger","")) == "tangle") for r in records ] + first_trip_idx = next((i for i, (c, t) in enumerate(zip(clog, tangle)) if c or t), None) + last_trip_idx = next((i for i in range(len(records) - 1, -1, -1) if clog[i] or tangle[i]), None) + + # Capture trip kind/reason *before* truncation so we can show a box + trip_kind = None + trip_reason = None + trip_index = first_trip_idx if stop_on_fg_trip else last_trip_idx if last_trip_idx == len(records) - 1 else None + if trip_index is not None: + trip_kind = "CLOG" if clog[trip_index] else "TANGLE" + trip_reason = records[trip_index].get("output", {}).get("flowguard", {}).get("reason") + + # Truncate all series on limit + end_idx = (first_trip_idx + 1) if (stop_on_fg_trip and first_trip_idx is not None) else len(records) + t_axis = t_axis[:end_idx] + + # Helper for optional series + def get_optional_series(records, key, end_idx, section="output"): + vals = [r.get(section, {}).get(key) for r in records[:end_idx]] + return [] if all(v is None for v in vals) else vals + + # Core series + rd = [r["output"]["rd_current"] for r in records[:end_idx]] + rd_tuned = [r["output"].get("rd_tuned", None) for r in records[:end_idx]] + z = [r["input"]["sensor"] for r in records[:end_idx]] + z_ui = [r["output"]["sensor_ui"] for r in records[:end_idx]] + mm_deltas = [r["input"]["d_mm"] for r in records[:end_idx]] + + # Normalized headroom: -1 = full, 0 = trigger + max_r_headroom = max(r["output"]["flowguard"]["relief_headroom"] for r in records[:end_idx]) + fg_r_headroom = [-r["output"]["flowguard"]["relief_headroom"] / max_r_headroom for r in records[:end_idx]] + + # Optional debug series + x_est = get_optional_series(records, "x_est", end_idx) + c_est = get_optional_series(records, "c_est", end_idx) + rd_target = get_optional_series(records, "rd_target", end_idx) + rd_ref = get_optional_series(records, "rd_ref", end_idx) + rd_ref_smoothed = get_optional_series(records, "rd_ref_smoothed", end_idx) + rd_note = get_optional_series(records, "rd_note", end_idx) + + has_x_est = any(v is not None for v in x_est) + has_c_est = any(v is not None for v in c_est) + has_rd_target = any(v is not None for v in rd_target) + has_rd_ref = any(v is not None for v in rd_ref) + has_rd_ref_smoothed = any(v is not None for v in rd_ref_smoothed) + + # Simulator only series + rd_true = get_optional_series(records, "rd_true", end_idx, section="truth") + spring_mm = get_optional_series(records, "spring_mm", end_idx, section="truth") + + has_rd_true = any(v is not None for v in rd_true) + has_spring_mm = any(v is not None for v in spring_mm) + + # Autotune markers (where controller reported a new default RD) + autotune_recommendation = [] + for i, r in enumerate(records[:end_idx]): + auto_rd = r.get("output", {}).get("autotune", {}).get("rd") + if auto_rd is not None: + autotune_recommendation.append((i, float(auto_rd))) + + # RD update "note" markers + rd_notes = [] + for i, n in enumerate(rd_note[:end_idx]): + if rd_note[i] is not None: + rd_notes.append((i, rd[i])) + + # FlowGuard armed markers + fg_armed = [] + has_fg_armed = False + for i, r in enumerate(records[:end_idx]): + armed = r.get("output", {}).get("flowguard", {}).get("active") + if armed is False: + has_fg_armed = True + fg_armed.append((i, -1.0)) + + # Header sparators representing reset() points in controller + header_break_idxs = [ + i for i, r in enumerate(records[:end_idx]) + if r.get("meta", {}).get("header_break") is True + ] + + # Main axes setup ------------------------------------------------------------- + fig, ax_rd = plt.subplots(figsize=(12, 6), constrained_layout=True) + ax_sensor = ax_rd.twinx() + + ax_rd.set_ylabel("Rotation Distance (mm)") + ax_rd.set_xlabel("Time (s)") + ax_rd.grid(True, axis="both", alpha=0.3) + + rd0 = rd[0] if rd else 0.0 + rd_min_required = rd0 * 0.75 + rd_max_required = rd0 * 1.25 + candidates_min = [rd_min_required] + candidates_max = [rd_max_required] + if rd: + candidates_min.append(min(rd)); candidates_max.append(max(rd)) + if show_rd_true and rd_true and all(v is not None for v in rd_true): + candidates_min.append(min(rd_true)); candidates_max.append(max(rd_true)) + rd_min = min(candidates_min) + rd_max = max(candidates_max) + if rd_min == rd_max: + span = abs(rd0) * 0.25 if rd0 != 0 else 1.0 + rd_min, rd_max = rd0 - span, rd0 + span + ax_rd.set_ylim(rd_min, rd_max) + + # Sensor axes/grid + ax_sensor.set_ylabel("Sensor") + ax_sensor.set_ylim(-1.1, 1.3) + #ax_sensor.axhline(0.0, linewidth=1.0, alpha=0.4, color="0.5", zorder=0) + ax_sensor.grid(True, axis="y", alpha=0.2) + + sensor_txt = f"Type {sensor_label}" if sensor_label else "Sensor" + title_txt = f"{title_txt} — {sensor_txt}" + if trip_kind is not None: + title_txt += f" | TRIPPED at {trip_kind}" + ax_rd.set_title(title_txt, pad=18) # Extra space above the top x-axis + ax_rd.text(0.5, 1.1, summary_txt, transform=ax_rd.transAxes, ha="center", va="bottom", fontsize=6, color="0.4", wrap=True, zorder=999) + + + # Plots against RD axis (left) ------------------------------------------------ + if len(t_axis) >= 2: + # Core... + if show_ticks: + ax_rd.plot(t_axis, rd, label="rotation_distance", linestyle="-", linewidth=MAIN_LW, color=RD_COLOR, marker="o", markersize=2.5, markevery=1) + else: + ax_rd.plot(t_axis, rd, label="rotation_distance", linestyle="-", linewidth=MAIN_LW, color=RD_COLOR) + ax_rd.plot(t_axis, rd_tuned, label="rd_tuned", linestyle="-.", linewidth=DEBUG_LW, color=RD_COLOR) + + # Debug... + if has_rd_true and show_rd_true: + ax_rd.plot(t_axis, rd_true, label="rd_true (simulator)", linestyle="-", linewidth=MAIN_LW, color=RD_TRUE_COLOR) + if has_rd_ref: + ax_rd.plot(t_axis, rd_ref, label="rd_ref", linestyle="-", linewidth=DEBUG_LW, color=RD_REF_COLOR) + if has_rd_ref_smoothed: + ax_rd.plot(t_axis, rd_ref_smoothed, label="rd_ref_smoothed", linestyle="--", linewidth=DEBUG_LW, color=RD_REF_COLOR) + if has_rd_target: + ax_rd.plot(t_axis, rd_target, label="rd_target", linestyle="-.", linewidth=DEBUG_LW, color=RD_REF_COLOR) + + elif t_axis: + ax_rd.scatter(t_axis, rd, label="rotation_distance", color=RD_COLOR, zorder=3) + if show_rd_true and has_rd_true: + ax_rd.scatter(t_axis, rd_true, label="rd_true (simulator)", color=RD_TRUE_COLOR, zorder=3) + + # Autotune dots (where controller reported a new default RD) + if autotune_recommendation: + t_marks = [t_axis[i] for (i, _) in autotune_recommendation] + y_marks = [v for (_, v) in autotune_recommendation] + ax_rd.scatter(t_marks, y_marks, marker="o", s=34, color=AUTOTUNE_COLOR, edgecolors="black", linewidths=0.6, label="RD autotune", zorder=AUTOTUNE_ZORDER) + + # RD note markers + if rd_notes: + t_marks = [t_axis[i] for (i, _) in rd_notes] + y_marks = [v for (_, v) in rd_notes] + ax_rd.scatter(t_marks, y_marks, marker="x", s=15, color=RD_COLOR, label="rd_note (extreme bias)") + + # Flowguard terminator line + for i, (is_clog, is_tangle) in enumerate(zip(clog[:end_idx], tangle[:end_idx])): + if is_clog or is_tangle: + ax_rd.axvline(t_axis[i], linestyle="-.", linewidth=BOLD_LW, alpha=0.7, color="red") + + # Draw header separators + for i in header_break_idxs: + ax_rd.axvline( + t_axis[i], + linestyle="--", + linewidth=1.5, + color="0.25", + alpha=0.6, + zorder=HEADER_ZORDER, + ) + + # Plots against sensor axis (right) ------------------------------------------- + if len(t_axis) >= 2: + # Core... + ax_sensor.plot(t_axis, z, label="sensor_reading", linestyle="-", linewidth=MAIN_LW, color=SENSOR_COLOR) + ax_sensor.plot(t_axis, z_ui, label="sensor_ui", linestyle=":", linewidth=SECONDARY_LW, color=SENSOR_UI_COLOR) + + # Debug... + if has_x_est: + ax_sensor.plot(t_axis, x_est, label="x_est (debug)", linestyle="--", linewidth=DEBUG_LW, color=X_EST_COLOR) + if has_c_est: + ax_sensor.plot(t_axis, c_est, label="c_est (debug)", linestyle="--", linewidth=DEBUG_LW, color=C_EST_COLOR) + + # FlowGuard: Headroom normalized and reflected to fit on this scale + ax_sensor.plot(t_axis, fg_r_headroom, label="flowguard headroom (norm. relief)", linestyle="-.", linewidth=SECONDARY_LW, color=HEADROOM_COLOR) + + elif t_axis: + ax_sensor.scatter(t_axis, z, label="sensor_reading", color=SENSOR_COLOR, zorder=2) + ax_sensor.scatter(t_axis, z_ui, label="sensor_ui", color=SENSOR_COLOR, zorder=2) + + # FlowGuard armed markers + if has_fg_armed: + t_marks = [t_axis[i] for (i, _) in fg_armed] + y_marks = [v for (_, v) in fg_armed] + ax_sensor.scatter(t_marks, y_marks, marker="x", s=15, color=HEADROOM_COLOR, label="flowguard not active") + + + # Plots against bowden/buffer spring (far right) ------------------------------ + if has_spring_mm: + ax_spring = ax_rd.twinx() + ax_spring.spines["right"].set_position(("axes", 1.08)) + ax_spring.spines["right"].set_visible(True) + ax_spring.spines["right"].set_color(LOWLIGHT_TEXT) + ax_spring.set_frame_on(True) + ax_spring.set_facecolor("none") + + ax_spring.axhline(0.0, linewidth=1.0, alpha=0.2, color="grey") + ax_spring.set_ylabel("Simulated bowden/buffer spring (mm)", color=LOWLIGHT_TEXT, fontsize=8) + ax_spring.tick_params(axis="y", colors=LOWLIGHT_TEXT, which="both", width=1, length=4) + + y_spring = [float("nan") if v is None else float(v) for v in spring_mm] + finite_vals = [v for v in y_spring if not (math.isnan(v) or math.isinf(v))] + span = max(abs(min(finite_vals)), abs(max(finite_vals))) if finite_vals else 1.0 + lim = max(span * 1.1, 0.5) + ax_spring.set_ylim(-lim, +lim) + + if len(t_axis) >= 2: + ax_spring.plot(t_axis, y_spring, label="bowden spring (simulator)", linestyle=":", linewidth=DEBUG_LW, color=SPRING_COLOR) + elif t_axis: + ax_spring.scatter(t_axis, y_spring, label="bowden spring (simulator)", color=SPRING_COLOR, zorder=2) + + + # Top non-linear extruder mm x-axis ------------------------------------------- + if show_mm_axis and len(t_axis) >= 2 and len(mm_deltas) >= 1: + # Fixed-distance ticks mapped to main time axis + t_series = np.asarray(t_axis, dtype=float) + + mode = (mm_axis_mode or "abs").lower() + if mode == "abs": + mm_series = np.cumsum(np.abs(mm_deltas)).astype(float) + top_label = "Extruder distance (mm)" + # ensure strictly increasing for stable inverse + if np.any(np.diff(mm_series) <= 0): + mm_series = mm_series + 1e-12 * np.arange(mm_series.size) + else: + # NOTE signed displacement may be non-monotonic → inverse not unique. + # We'll still place ticks using the ABS distance for spacing, + # but show signed labels at those times. + mm_series_signed = np.cumsum(mm_deltas).astype(float) + mm_series = np.cumsum(np.abs(mm_deltas)).astype(float) # for monotonic inverse + top_label = "Extruder displacement (mm)" + if np.any(np.diff(mm_series) <= 0): + mm_series = mm_series + 1e-12 * np.arange(mm_series.size) + + # Make sure first label is exactly 0 at t0 + if mm_series.size: + mm_series[0] = 0.0 + + # Add small symmetric margin so both axes inset equally + ax_rd.margins(x=0.03) + + # Create the top axis that shares time-limits with the bottom + ax_mm = ax_rd.twiny() + ax_mm.set_xlim(ax_rd.get_xlim()) + ax_mm.spines["top"].set_color(LOWLIGHT_TEXT) + ax_mm.tick_params(axis="x", colors=LOWLIGHT_TEXT, pad=6, labelsize=6) + ax_mm.xaxis.label.set_color(LOWLIGHT_TEXT) + ax_mm.set_xlabel(top_label, fontsize=8) + ax_mm.xaxis.get_offset_text().set_size(6) + + # Fixed-distance tick placement + mm_end = float(mm_series[-1]) + + def _nice_step(x): + # 1–2–5 stepping + if x <= 0: + return 1.0 + exp = math.floor(math.log10(x)) + frac = x / (10 ** exp) + if frac <= 1.0: + nice = 1.0 + elif frac <= 2.0: + nice = 2.0 + elif frac <= 5.0: + nice = 5.0 + else: + nice = 10.0 + return nice * (10 ** exp) + + # Aim for ~10 major ticks + desired_ticks = 10 # tweak: 8, 10, 12, ... + target = max(1.0, mm_end / desired_ticks) + mm_step = _nice_step(target) + + # Build distance ticks: 0, step, 2*step, ... + m_ticks = np.arange(0.0, mm_end + 0.5 * mm_step, mm_step) + + # Optionally drop the terminal tick if it lands essentially at the end + # to avoid the last label colliding with the frame. + if len(m_ticks) >= 2 and abs(m_ticks[-1] - mm_end) < 0.25 * mm_step: + m_ticks = m_ticks[:-1] + + # Map distance ticks → time via inverse interp + t_ticks = np.interp(m_ticks, mm_series, t_series) + + # Keep ticks within the current (padded) visible time range + xmin, xmax = ax_rd.get_xlim() + keep = (t_ticks >= xmin) & (t_ticks <= xmax) + t_ticks = t_ticks[keep] + m_ticks = m_ticks[keep] + + # Labels: show signed value at those times if in 'signed' mode + if mode == "signed": + signed_vals = np.interp(t_ticks, t_series, mm_series_signed) + labels = [f"{v:.0f}" if mm_step >= 5 else f"{v:.1f}" for v in signed_vals] + else: + labels = [f"{m:.0f}" if mm_step >= 5 else f"{m:.1f}" for m in m_ticks] + + ax_mm.set_xticks(t_ticks) + ax_mm.set_xticklabels(labels) + + + # Plot times where an actual movement happened (ticks) ------------------------ + if show_tick_times: + tick_times = [t for t, d in zip(t_axis, mm_deltas)] + + # Keep them just under the top, and a bit lower if the top distance axis is shown + y_top = 0.99 + h = 0.015 # height = 1.5% of axes + y0, y1 = max(0, y_top - h), min(1, y_top) + + for x in tick_times: + ax_rd.axvline(x, ymin=y0, ymax=y1, color=TICK_MARK_COLOR, linewidth=1.0, clip_on=False, label="_nolegend_") + + + # Flowguard trip banner ------------------------------------------------------- + if trip_kind is not None: + ax_rd.text( + 0.01, 0.98, + f"{trip_kind} reason:\n{trip_reason}", + transform=ax_rd.transAxes, + va="top", ha="left", + fontsize=9, + wrap=True, + bbox=dict(boxstyle="round", facecolor="0.92", edgecolor="0.6", alpha=0.8), + zorder=TRIPBOX_ZORDER, + clip_on=False, + ) + + + # Legend box ------------------------------------------------------------------ + rd_lines, rd_labels = ax_rd.get_legend_handles_labels() + sensor_lines, sensor_labels = ax_sensor.get_legend_handles_labels() + if has_spring_mm: + spring_lines, spring_labels = ax_spring.get_legend_handles_labels() + else: + spring_lines, spring_labels = [], [] + + legend = ax_sensor.legend( + rd_lines + sensor_lines + spring_lines, + rd_labels + sensor_labels + spring_labels, + loc="lower left", + ncol=3, + fontsize=8, + framealpha=0.6, + borderpad=0.2, + labelspacing=0.25, + handlelength=2.0, + handletextpad=0.4, + columnspacing=1.2, + ) + + # Plot ------------------------------------------------------------------------ + if out_path: + plt.savefig(out_path, dpi=150) + plt.close(fig) + print(f"Saved plot to {out_path}") + else: + plt.show() + + +# ------------------------------ CLI Helpers ---------------------------- + +def _print_cli_help(): + print(""" +Commands (history enabled — use Up/Down arrows): + sim [rd] - Simulate average extruder speed for time. Uses --stride-mm chunk size per update. + inout [v_mm_s [rd]] - Simulate average extruder speed for time. Uses --stride-mm chunk size per update. + t|tick [] - Manual one update. If sensor omitted or 'auto', uses simulator sensor state. + rd - Set printer's *true* extruder rotation_distance immediately. + clog - Realistic compression-extreme test (build + stuck), stop on FlowGuard. + tangle - Realistic tension-extreme test (build + stuck), stop on FlowGuard. + clear - Reset controller (full), printer spring, and jsonl log file (tick=0). + p | plot - Save plot to sim_plot.png (always saves to file). + d | display - Display plot window (does not save). + status - Show controller/printer state + quit | q - Exit +""") + +def _summary_txt(ctrl: SyncController, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard: Optional[Dict[str, Any]], spring_mm: float): + autotune_str = "N/A" if last_autotune_rd is None else f"{last_autotune_rd:.4f}" + sensor_str = "N/A" if last_sensor is None else (f"{last_sensor:.3f}" if isinstance(last_sensor, float) else str(last_sensor)) + sensor_ui_str = "N/A" if last_sensor_ui is None else f"{last_sensor_ui:.3f}" + if isinstance(last_flowguard, dict) and last_flowguard.get('trigger'): + fg_str = f"trigger={last_flowguard.get('trigger', '')}, reason={last_flowguard.get('reason')}" + else: + fg_str = "N/A" + return f"RD={ctrl.rd_current:.4f} | Autotune={autotune_str} | sensor={sensor_str} | sensor_ui={sensor_ui_str} | Bowden/Buffer spring={spring_mm:.3f}mm | FlowGuard: {fg_str}" + +def _summary_print(ctrl: SyncController, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard: Optional[Dict[str, Any]], spring_mm: float): + summary_txt = _summary_txt(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, spring_mm) + print(f"SUMMARY: {summary_txt}") + +def _plot_from_log( + cfg: SyncControllerConfig, + logger: SimLogger, + *, + mode: str, + out_path: str = "sim_plot.png", + show_ticks: bool = False, + show_mm_axis: bool = False, + mm_axis_mode: str = "abs", + summary_txt: str = "", +): + # Drop any header rows from the in-session simulator log + raw = logger.load_all() + records = [r for r in raw if not (isinstance(r, dict) and "header" in r)] + if not records: + print("jsonl log file is empty; nothing to plot.") + return + + use_twolevel = cfg.use_twolevel_for_type_p or cfg.sensor_type in ['CO', 'TO'] + twolevel_txt = " (twoLevel)" if use_twolevel else "" + sensor_txt = f"{cfg.sensor_type}{twolevel_txt}" if cfg.sensor_type else None + try: + plot_progress( + records, + out_path=out_path if mode == "save" else None, + sensor_label=sensor_txt, + rd_start=cfg.rd_start, + show_rd_true=True, + show_ticks=show_ticks, + show_mm_axis=show_mm_axis, + mm_axis_mode=mm_axis_mode, + summary_txt=summary_txt + ) + except Exception as e: + print(f"Plotting failed: {e}") + raise e + +# -------------------------- Controller log plotting --------------------- + +def _load_log_file(path: str) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + """ + Load a controller-generated JSON log. + Supports JSONL (one JSON object per line) or a single JSON array. + First object/element may be {"header": {...}}; subsequent entries are {"input": {...}, "output": {...}}. + Returns (header_dict_or_empty, records_list). + """ + header: Dict[str, Any] = {} + records: List[Dict[str, Any]] = [] + pending_break = False + + if not os.path.exists(path): + raise FileNotFoundError(f"No such file: {path}") + + with open(path, "r", encoding="utf-8") as f: + raw = f.read().strip() + + if not raw: + return header, records + + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + if isinstance(obj, dict) and "header" in obj and not header: + maybe = obj.get("header") + if isinstance(maybe, dict): + header = maybe + continue + + if isinstance(obj, dict) and "header" in obj and header: + pending_break = True + continue + + if isinstance(obj, dict) and ("input" in obj or "output" in obj): + if pending_break: + obj.setdefault("meta", {}) + obj["meta"]["header_break"] = True + pending_break = False + records.append(obj) + + return header, records + +def _plot_log_file(path: str, *, out_path: str, show_ticks: bool, show_mm_axis: bool, mm_axis_mode: str): + header, raw_records = _load_log_file(path) + if not raw_records: + print("Controller log has no data rows to plot.") + return + + # Build plot-ready records: + # - Copy each record + # - Inject per-record meta.dt_s from input.dt_s (preferred) + # - Fall back to any existing meta.dt_s if input.dt_s is absent (e.g., simulator logs) + records: List[Dict[str, Any]] = [] + for r in raw_records: + rr = dict(r) + try: + dt = float(r.get("input", {}).get("dt_s")) + except Exception: + dt = r.get("meta", {}).get("dt_s", 1.0) # defensive fallback + + rr_meta = dict(r.get("meta", {})) + rr_meta["dt_s"] = dt + rr["meta"] = rr_meta + + records.append(rr) + + # Detect whether truth is present (simulator logs carry this) + has_truth = any(isinstance(r.get("truth"), dict) for r in raw_records) + + # Pull values used only for labeling/summary if in existing log + rd_start = header.get("rd_start", None) + sensor_type = header.get("sensor_type", None) + twolevel_active = header.get("twolevel_active", None) + + # Compose a small summary from the last record + last = records[-1] + sensor_last = last.get("input", {}).get("sensor") + rd_current_last = last.get("output", {}).get("rd_current") + sensor_ui_last = last.get("output", {}).get("sensor_ui") + fg_last = last.get("output", {}).get("flowguard", {}) + # Last non-None autotune rd + autotune_last = next( + (r["output"]["autotune"]["rd"] + for r in reversed(records) + if r.get("output", {}).get("autotune", {}).get("rd") is not None), + None, + ) + spring_mm = last.get("truth", {}).get("spring_mm") + + twolevel_txt = " (twoLevel)" if twolevel_active else "" + sensor_txt = f"{sensor_type}{twolevel_txt}" if sensor_type else None + + summary_parts = [] + if rd_start is not None and rd_current_last is not None: + summary_parts.append(f"RD start={rd_start:.4f}, end={rd_current_last:.4f}") + if autotune_last is not None: + summary_parts.append(f"Autotune={autotune_last:.4f}") + if sensor_last is not None: + try: + summary_parts.append(f"sensor={float(sensor_last):.3f}") + except Exception: + summary_parts.append(f"sensor={sensor_last}") + if sensor_ui_last is not None: + summary_parts.append(f"sensor_ui={float(sensor_ui_last):.3f}") + if spring_mm is not None: + summary_parts.append(f"Bowden/Buffer spring={float(spring_mm):.3f}mm") + #if fg_last: + # summary_parts.append(f"FlowGuard: clog={fg_last.get('clog')}, tangle={fg_last.get('tangle')}, reason={fg_last.get('reason')}") + summary_txt = " | ".join(summary_parts) + + # Save… then display... + for p in [out_path, None]: + plot_progress( + records, + out_path=p, + dt_s=None, # let plot() use per-record meta.dt_s + sensor_label=sensor_txt, + rd_start=rd_start, + show_rd_true=has_truth, # show truth if present (sim logs) + show_ticks=show_ticks, + show_mm_axis=show_mm_axis, + mm_axis_mode=mm_axis_mode, + summary_txt=summary_txt, + title_txt="Debug Sync Plot" + ) + +# -------------------------- Extreme Test (realistic) ------------------- + +def _forced_extreme_test( + ctrl: SyncController, + logger: SimLogger, + printer: SimplePrinterModel, + kind: str, # "clog" or "tangle" + stride_mm: float, + dt_s_step: float, +) -> List[Dict[str, Any]]: + """ + Two-phase extreme test with a printer-side fault prelude. + Physics is driven by the model helpers so the sign convention is consistent + with the rest of the simulator. + + - Always feeds forward (+d_ext) in the ramp. + - "clog" adds *compression* per mm (positive Δx overlay). + - "tangle" adds *tension* per mm (negative Δx overlay). + - Always passes an absolute timestamp into ctrl.update(). + """ + cfg = ctrl.cfg + records: List[Dict[str, Any]] = [] + + # Feed length per tick during the test. + build_mm = max(stride_mm, cfg.buffer_range_mm / 2.0) + d_ext_build = +abs(build_mm) # forward extrude for both tests + + # Fault strength (fraction of commanded mm converted into extra relative motion) + fault_frac = 0.35 + + # Normalization and limits + K = 2.0 / cfg.buffer_range_mm + norm_limit = max(1e-9, cfg.buffer_max_range_mm / cfg.buffer_range_mm) + + # When do we consider the sensor "pegged" during the ramp? + peg_thr = max(0.9, cfg.flowguard_extreme_threshold) + peg_need = 2 # require N consecutive pegged samples + peg_count = 0 + + # Sign of the overlay term added by the fault + fault_sign = +1.0 if kind == "clog" else -1.0 + + # --------------------------- + # Phase 1: RAMP with a fault + # --------------------------- + ramp_ticks_max = 400 + for _ in range(ramp_ticks_max): + # PRE-MOVE read (for the controller's input at this boundary) + z_in = printer.measure() + + # Advance time first, then update controller with the commanded motion + rd_prev = ctrl.rd_current + sim_time_s = printer.advance_time(dt_s_step) + out = ctrl.update(sim_time_s, d_ext_build, z_in, simulation=True) + + # Physical evolution for this tick: + # 1) Base RD-mismatch physics driven by rd_prev + printer.advance_physics(rd_prev, d_ext_build) + + # 2) Fault overlay: +compression for clog, -tension for tangle + printer.x_true += K * (fault_sign * fault_frac * d_ext_build) + + # Clip to physical limits + printer.x_true = min(max(printer.x_true, -norm_limit), norm_limit) + + # Log row + rec = { + **out, + "truth": { + "rd_true": printer.extruder_rd_true, + "spring_mm": printer.spring_mm(), + "x_true": printer.x_true, + "x_meas": printer.x_meas, + }, + "meta": { + "dt_s": dt_s_step, + "t_s": sim_time_s, + "phase": "ramp", + "fault": kind, + }, + } + logger.append(rec); records.append(rec) + + # Check pegging + if cfg.sensor_type == "P": + pegged_now = (abs(printer.x_true) >= peg_thr) + elif cfg.sensor_type == "D": + m = printer.measure() + pegged_now = (m == +1 and kind == "clog") or (m == -1 and kind == "tangle") + elif cfg.sensor_type == "CO": + # CO sees compression side only; for tangle, look at the hidden side via state + pegged_now = (printer.measure() == 1) if kind == "clog" else (printer.x_true <= -peg_thr) + else: # "TO" + # TO sees tension side only; for clog, look at the hidden side via state + pegged_now = (printer.measure() == -1) if kind == "tangle" else (printer.x_true >= +peg_thr) + + peg_count = peg_count + 1 if pegged_now else 0 + if peg_count >= peg_need: + # Snap to the physical extreme so the stuck phase starts fully pegged. + printer.x_true = (+norm_limit if kind == "clog" else -norm_limit) + break + + # FlowGuard? + fg = out["output"]["flowguard"] + trip_kind = fg.get("trigger") + if trip_kind: + trip_kind = trip_kind.upper() + print(f"FlowGuard {trip_kind} detected during ramp-in") + ctrl.flowguard.reset() + return records + + # ----------------------------- + # Phase 2: STUCK (hard jam) + # ----------------------------- + stuck_ticks_max = 120 + for _ in range(stuck_ticks_max): + # Force the state to the extreme *before* sampling so measured matches stuck + printer.x_true = (+norm_limit if kind == "clog" else -norm_limit) + z_in = printer.measure() + + rd_prev = ctrl.rd_current + sim_time_s = printer.advance_time(dt_s_step) + out = ctrl.update(sim_time_s, d_ext_build, z_in, simulation=True) + + # Keep it pinned (no need to evolve physics here; the jam dominates) + printer.x_true = (+norm_limit if kind == "clog" else -norm_limit) + + rec = { + **out, + "truth": { + "rd_true": printer.extruder_rd_true, + "spring_mm": printer.spring_mm(), + "x_true": printer.x_true, + "x_meas": printer.x_meas, + }, + "meta": { + "dt_s": dt_s_step, + "t_s": sim_time_s, + "phase": "stuck", + "fault": kind, + }, + } + logger.append(rec); records.append(rec) + + fg = out["output"]["flowguard"] + trip_kind = fg.get("trigger") + if trip_kind: + trip_kind = trip_kind.upper() + print(f"FlowGuard {trip_kind} detected") + ctrl.flowguard.reset() + break + + return records + +def _make_seed_record(ctrl: SyncController, printer: SimplePrinterModel, t_s: float, sensor_val: float | int) -> Dict[str, Any]: + """Builds the very first log row in the new schema.""" + sensor_ui = float(max(-1.0, min(1.0, float(sensor_val)))) if isinstance(sensor_val, (int, float)) else 0.0 + return { + "input": { + "tick": 0, + "dt_s": 0.0, + "t_s": t_s, + "d_mm": 0.0, + "sensor": sensor_val, + }, + "output": { + "rd_target": ctrl.rd_ref, + "rd_ref": ctrl.rd_ref, + "rd_ref_smoothed": ctrl.rd_ref, + "rd_current": ctrl.rd_ref, + "rd_note": "seed", + "x_est": ctrl.state.x, + "c_est": ctrl.state.c, + "sensor_ui": sensor_ui, + "flowguard": {"trigger": "", "reason": "", "level": 0.0, "max_clog": 0.0, "max_tangle": 0.0, "active": False, "relief_headroom": -1.0}, + "autotune": {"rd": None, "note": None}, + }, + "truth": { + "rd_true": printer.extruder_rd_true, + "spring_mm": printer.spring_mm(), + "x_true": printer.x_true, + "x_meas": printer.x_meas, + }, + "meta": { + "dt_s": 0.0, + "t_s": t_s, + }, + } + +# ---------------------------------- CLI -------------------------------- + +def _run_cli(): + random.seed(time.time_ns()) + _setup_readline(history_limit=500) + + ap = argparse.ArgumentParser(description="Filament Tension Controller + Printer Simulator CLI (movement-based)") + ap.add_argument("--sensor-type", choices=["P", "D", "CO", "TO"], default="P") + ap.add_argument("--buffer-range-mm", type=float, default=8.0) + ap.add_argument("--buffer-max-range-mm", type=float, default=12.0) + ap.add_argument("--use-twolevel", dest="use_twolevel", action="store_true", help="Enable user two-level behavior for type-P sensor") + ap.set_defaults(use_twolevel=False) + ap.add_argument("--tick-dt-s", type=float, default=1.0, help="default dt used only for manual 'tick', 'clog' and 'tangle'") + ap.add_argument("--rd-start", type=float, default=20.0, help="starting extruder rotation distance") + ap.add_argument("--sensor-lag-mm", type=float, default=0.0) + ap.add_argument("--stride-mm", type=float, default=5.0, help="movement per controller update during 'sim' and extreme tests") + ap.add_argument("--initial-sensor", choices=["neutral", "random"], default="neutral", + help="Initial sensor reading used for startup/reset (default: neutral).") + ap.add_argument("--stride-only", dest="stride_only", action="store_true", help="Only update on stride boundary (suppress real-time flips)") + ap.add_argument("--chaos", type=float, default=0.0, help="Stick-slip in measured sensor: 0.0=exact (today), 2.0=max jerk.") + ap.add_argument("--sample-error", type=float, default=0, + help="Randomize each sim tick to be stride * (1 + u*sample_error) with u∈[0,1]. " + "Only increases per-tick size; the final tick catches up so total distance is exact. " + "Use 0.0 to retain the prior exact, regular tick behavior.") + ap.add_argument("--switch-hysteresis", type=float, default=0.2, + help="Hysteresis factor for switch sensors (D/CO/TO). " + "Trigger at thr*(1+factor), release at thr*(1-factor). 0 disables.") + # Artifact outputs + ap.add_argument("--log-debug", dest="log_debug", action="store_true", help="Display debug trace log entries (autotune)") + ap.set_defaults(log_debug=False) + ap.add_argument("--out", type=str, default="sim_plot.png", help="Output PNG filename for plots (default: sim_plot.png).") + ap.add_argument("--log", type=str, default="sim.jsonl", help="Simulator json log output.") + + # Controller log plotting + ap.add_argument("--plot", type=str, default=None, + help="Path to a controller-generated JSON log (JSONL or JSON array). " + "If set, the log is parsed, a plot is saved to --out and displayed,") + ap.add_argument("--show-ticks", dest="show_ticks", action="store_true", help="Show individual updates on plot") + ap.add_argument("--x-mm", choices=["off", "abs", "signed"], default="abs", + help="Top x-axis in extruder mm: 'abs' = total distance (monotonic), 'signed' = net displacement.") + args = ap.parse_args() + + # If a controller log is provided, plot it and exit (no simulator session). + if args.plot: + try: + _plot_log_file( + args.plot, + out_path=args.out, + show_ticks=bool(args.show_ticks), + show_mm_axis=(args.x_mm != "off"), + mm_axis_mode=("abs" if args.x_mm == "abs" else "signed"), + ) + except Exception as e: + print(f"Failed to plot controller log: {e}") + raise e + return + + cfg = SyncControllerConfig( + log_sync=True, # tell controller to also create log trace for debugging + buffer_range_mm=args.buffer_range_mm, + buffer_max_range_mm=args.buffer_max_range_mm, + use_twolevel_for_type_p=args.use_twolevel, + sensor_type=args.sensor_type, + rd_start=args.rd_start, + sensor_lag_mm=args.sensor_lag_mm, + ) + default_dt_s = float(args.tick_d_t if False else args.tick_dt_s) + + ctrl = SyncController(cfg) + logger = SimLogger(args.log, truncate_on_init=True) + + logger.write_header({ + "rd_start": cfg.rd_start, + "sensor_type": cfg.sensor_type, + "twolevel_active": bool(cfg.use_twolevel_for_type_p or cfg.sensor_type in ['CO', 'TO']), + "buffer_range_mm": cfg.buffer_range_mm, + "buffer_max_range_mm": cfg.buffer_max_range_mm, + "switch_hysteresis": args.switch_hysteresis, + "chaos": args.chaos, + "sample_error": args.sample_error, + }) + + printer = SimplePrinterModel( + ctrl, + extruder_rd_true=None, + initial_spring_mm=0.0, + chaos=max(0.0, min(2.0, args.chaos)), + hysteresis=max(0.0, min(0.4, args.switch_hysteresis)), + ) + + # --------- Set initial sensor state (neutral/random) BEFORE reset ---------- + if args.initial_sensor == "random": + thr = cfg.flowguard_extreme_threshold + norm_clip = max(1e-9, cfg.buffer_max_range_mm / cfg.buffer_range_mm) + + if cfg.sensor_type == "P": + x0 = random.uniform(-1.0, 1.0) + elif cfg.sensor_type == "D": + choice = random.choice([-1, 0, 1]) + if choice == 0: + x0 = random.uniform(-0.8 * thr, 0.8 * thr) + elif choice == 1: + x0 = min(norm_clip, random.uniform(thr + 0.05, thr + 0.5)) + else: + x0 = -min(norm_clip, random.uniform(thr + 0.05, thr + 0.5)) + elif cfg.sensor_type == "CO": + choice = random.choice([0, 1]) + if choice == 1: + x0 = min(norm_clip, random.uniform(thr + 0.05, thr + 0.5)) + else: + x0 = random.uniform(-min(norm_clip, thr - 0.05), thr - 0.05) + elif cfg.sensor_type == "TO": + choice = random.choice([0, -1]) + if choice == -1: + x0 = -min(norm_clip, random.uniform(thr + 0.05, thr + 0.5)) + else: + x0 = random.uniform(-thr + 0.05, min(norm_clip, thr - 0.05)) + else: + x0 = 0.0 + + printer.x_true = max(-norm_clip, min(norm_clip, x0)) + printer.x_meas = printer.x_true # start measured at modeled state + # re-bootstrap last switch value after manual override of x + printer._bootstrap_switch_state() + # --------------------------------------------------------------------- + + # Simulation clock (absolute time seconds since start) lives on the printer + printer.reset_time(0.0) + + # Take ONE reading and use it consistently for reset and the seed log row + z0 = printer.measure() + ctrl.reset(printer.get_time_s(), cfg.rd_start, z0, simulation=True) + + # Append a seed sample at t=0 so plots (and top mm axis) start at zero + seed_rec = _make_seed_record(ctrl, printer, printer.get_time_s(), z0) + logger.append(seed_rec) + + # Make the initial summary reflect the seed + last_sensor = z0 + oo = seed_rec["output"] + last_sensor_ui = oo["sensor_ui"] + last_flowguard = oo["flowguard"] + last_autotune_rd = None + + # Show whichever attribute exists on cfg + print("=== Filament Tension Controller CLI ===") + print(f" Sensor Type : {cfg.sensor_type}") + print(f" Use TwoLevel : {cfg.use_twolevel_for_type_p}") + print(f" Buffer Range (sensor) : {cfg.buffer_range_mm} mm") + print(f" Buffer Max Range : {cfg.buffer_max_range_mm} mm (physical limit)") + print(f" Autotune motion : {cfg.autotune_motion_mm} mm") + print(f" Flowguard relief : {cfg.flowguard_relief_mm} mm") + print(f" MMU Gear RD start : {cfg.rd_start} mm") + print(f" Sensor lag : {cfg.sensor_lag_mm} mm") + print(f" Simulator:") + print(f" Chaos factor : {args.chaos}") + print(f" Ext sample error : {args.sample_error}") + switch_hysteresis = args.switch_hysteresis if cfg.sensor_type != "P" else "n/a" + print(f" Switch hysteresis : {switch_hysteresis}") + print(f" Initial sensor mode : {args.initial_sensor}") + print(f" Stride per update : {args.stride_mm} mm (sim, clog & tangle)") + print(f" Default dt : {default_dt_s} s (manual 'tick' & clog/tangle test)") + print(f" JSON log : {logger.path}") + if not HAVE_READLINE: + print(" (Tip: install 'pyreadline3' on Windows to enable Up/Down history)") + _print_cli_help() + + # --------------------------------------------------------------------- + + while True: + try: + line = input("cmd> ").strip() + except (EOFError, KeyboardInterrupt): + print() + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + _plot_from_log( + cfg, logger, + mode="save", + out_path=args.out, + show_ticks=args.show_ticks, + show_mm_axis=(args.x_mm != "off"), + mm_axis_mode=("abs" if args.x_mm == "abs" else "signed"), + summary_txt=_summary_txt(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + ) # save on exit + break + + if not line: + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + _add_history(line) + low = line.lower() + + # ---------------------------------------------------------------------- + if low in ("q", "quit", "exit"): + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + break + + # ---------------------------------------------------------------------- + if low in ("h", "help"): + _print_cli_help() + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + if low in ("p", "plot"): + _plot_from_log( + cfg, logger, + mode="save", + out_path=args.out, + show_ticks=args.show_ticks, + show_mm_axis=(args.x_mm != "off"), + mm_axis_mode=("abs" if args.x_mm == "abs" else "signed"), + summary_txt=_summary_txt(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + ) + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + if low in ("d", "display"): + _plot_from_log( + cfg, logger, + mode="display", + show_ticks=args.show_ticks, + show_mm_axis=(args.x_mm != "off"), + mm_axis_mode=("abs" if args.x_mm == "abs" else "signed"), + summary_txt=_summary_txt(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + ) + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + if low == "status": + print(f" RD: {ctrl.rd_current:.4f} mm | x={ctrl.state.x:.3f} | c={ctrl.state.c:.4f} | " + f"Bowden/Buffer spring={printer.spring_mm():.3f}mm | printer_RD_true={printer.extruder_rd_true:.4f}") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + if low == "clear": + # Clear log and reconstruct printer first + logger.clear() + # Re-write header after clearing (kept consistent with startup) + logger.write_header({ + "rd_start": cfg.rd_start, + "sensor_type": cfg.sensor_type, + "twolevel_active": bool(cfg.use_twolevel_for_type_p), + "buffer_range_mm": cfg.buffer_range_mm, + "buffer_max_range_mm": cfg.buffer_max_range_mm, + "switch_hysteresis": args.switch_hysteresis, + "chaos": args.chaos, + "sample_error": args.sample_error, + }) + printer = SimplePrinterModel( + ctrl, + extruder_rd_true=printer.extruder_rd_true, + initial_spring_mm=0.0, + chaos=max(0.0, min(2.0, args.chaos)), + hysteresis=args.switch_hysteresis, + ) + + # Reset clock and controller with timestamp + printer.reset_time(0.0) + z0 = printer.measure() + ctrl.reset(printer.get_time_s(), cfg.rd_start, z0, simulation=True) + + # Seed t=0 record + seed_rec = _make_seed_record(ctrl, printer, printer.get_time_s(), z0) + logger.append(seed_rec) + + # Make the initial SUMMARY reflect the seed + last_sensor = z0 + oo = seed_rec["output"] + last_sensor_ui = oo["sensor_ui"] + last_flowguard = oo["flowguard"] + last_autotune_rd = None + + print("Controller reset, printer spring rebased, and log cleared. Tick counter reset to 0.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + if low.startswith("rd "): + parts = line.split() + if len(parts) != 2: + print("Usage: rd ") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + try: + new_rd = float(parts[1]) + except ValueError: + print("Bad numeric value.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + printer.set_extruder_rd_true(new_rd) + print(f"Printer true extruder RD set to {printer.extruder_rd_true:.4f} mm.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + if low == "clog" or low == "tangle": + kind = "clog" if low == "clog" else "tangle" + print(f"Realistic {kind} test (autotune state, stride={args.stride_mm} mm, dt={default_dt_s}s, stop on FlowGuard)...") + recs = _forced_extreme_test( + ctrl, logger, printer, + kind=kind, + stride_mm=args.stride_mm, + dt_s_step=default_dt_s, + ) + if recs: + last_sensor = recs[-1].get("input", {}).get("sensor") + oo = recs[-1].get("output", {}) + last_sensor_ui = oo.get("sensor_ui") + last_flowguard = oo.get("flowguard") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + # Distance-based simulation: "sim [rd]" + if line.startswith("sim "): + parts = line.split() + if len(parts) not in (3, 4): + print("Usage: sim []") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + try: + v = float(parts[1]); T = float(parts[2]) + rd_true = float(parts[3]) if len(parts) == 4 else None + except ValueError: + print("Bad numeric values.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + if rd_true is not None: + printer.set_extruder_rd_true(rd_true) + + total_mm = v * T + stride = abs(args.stride_mm) + max_stride = stride * (1.0 + max(0.0, args.sample_error)) + n_steps = max(1, int(round(abs(total_mm) / max(1e-9, stride)))) + per_step = math.copysign(abs(total_mm) / n_steps, total_mm) + dt_s = (abs(per_step) / abs(v)) if abs(v) > 1e-12 else default_dt_s + max_str = f"-{max_stride:.1f}mm" if args.sample_error > 0 else "" + + print( + f"Running sim: total={total_mm:.3f}mm at {v}mm/s | approx steps={n_steps} | " + f"stride≈{stride:.1f}mm{max_str} | printer_RD_true={printer.extruder_rd_true:.4f}mm | " + f"spring0={printer.spring_mm():.3f}mm ..." + ) + + # --- Helpers ------------------------------------------------------- + def _new_stride_len(rem: float) -> float: + base = max(1e-9, abs(args.stride_mm)) + if args.sample_error <= 1e-12: + return min(base, rem) + return min(base * (1.0 + random.random() * args.sample_error), rem) + + def _read_sensor(): + raw = printer.measure() + return int(raw) if cfg.sensor_type in ("CO", "TO", "D") else float(raw) + + def _next_flip_distance(sensor_type: str, z0: int, x: float, g: float, s: float): + """ + Distance (>=0) along commanded extrusion to next threshold crossing, else inf. + g = (2/BR)*(RD_true/RD_prev - 1) is Δx per +1 mm extruder (normalized). + """ + if sensor_type == "P" or abs(g) <= 1e-16: + return math.inf, None + thr_on, thr_off = printer.get_switch_thresholds() + xvel_pos = (g * s) > 0.0 + targets = [] + if sensor_type == "CO": + if z0 == 0 and xvel_pos: targets.append(+thr_on) + elif z0 == 1 and not xvel_pos: targets.append(+thr_off) + elif sensor_type == "TO": + if z0 == 0 and not xvel_pos: targets.append(-thr_on) + elif z0 == -1 and xvel_pos: targets.append(-thr_off) + elif sensor_type == "D": + if z0 == 0: + targets.append(+thr_on if xvel_pos else -thr_on) + elif z0 == 1 and not xvel_pos: + targets.append(+thr_off) + elif z0 == -1 and xvel_pos: + targets.append(-thr_off) + + lag_norm = 0.0 + if targets and printer.chaos > 1e-12: + lag_mm_max = min(cfg.buffer_max_range_mm, float(printer.chaos) * cfg.buffer_max_range_mm) + lag_norm = (2.0 / cfg.buffer_range_mm) * (random.random() * lag_mm_max) + + best_d = math.inf + best_anchor = None + norm_clip = max(1e-9, cfg.buffer_max_range_mm / cfg.buffer_range_mm) + for anchor in targets: + x_target = anchor + (lag_norm if xvel_pos else -lag_norm) + x_target = max(-norm_clip + 1e-12, min(norm_clip - 1e-12, x_target)) + d_needed = (x_target - x) / g + if d_needed * s > 1e-12: + d_abs = abs(d_needed) + if d_abs < best_d: + best_d = d_abs + best_anchor = anchor + return best_d, best_anchor + + # --- Rolling-stride event scheduler ------------------------------- + sgn = 1.0 if total_mm >= 0 else -1.0 + remaining = abs(total_mm) + stride_left = _new_stride_len(remaining) + eps = 1e-12 + is_discrete = cfg.sensor_type in ("CO", "TO", "D") + + while remaining > eps: + # PRE-MOVE read + z0 = _read_sensor() + + # RD in effect for the chunk we are about to simulate + rd_in_effect = getattr(ctrl, "rd_prev", ctrl.rd_current) + + # Normalized gain per +1 mm extruder + g = printer.gain_per_mm(rd_in_effect) + s = 1.0 if sgn > 0 else -1.0 + + # Predict next flip and stride + x_now = printer.x_true + if cfg.sensor_type == "P": + d_to_flip, _flip_anchor = math.inf, None + else: + d_to_flip, _flip_anchor = _next_flip_distance(cfg.sensor_type, int(z0), x_now, g, s) + d_to_stride = stride_left + + # Advance to earliest of (remaining, flip, stride) + chunk_abs = min(remaining, d_to_stride, d_to_flip) + d_chunk = sgn * chunk_abs + dt_chunk = (chunk_abs / abs(v)) if abs(v) > 1e-12 else default_dt_s + sim_time_s = printer.advance_time(dt_chunk) + + # Physical spring evolution using rd_in_effect + printer.advance_physics(rd_in_effect, d_chunk) + + # Measure at event boundary + z1 = _read_sensor() + + # Event classification + if is_discrete: + actually_flipped = (int(z1) != int(z0)) + else: + actually_flipped = False + predicted_flip = abs(chunk_abs - d_to_flip) <= 1e-12 + hit_stride = abs(chunk_abs - d_to_stride) <= 1e-12 + + emit = (actually_flipped and not args.stride_only) or hit_stride + if emit: + out = ctrl.update(sim_time_s, d_chunk, z1, simulation=True) + + rec = { + **out, + "truth": { + "rd_true": printer.extruder_rd_true, + "spring_mm": printer.spring_mm(), + "x_true": printer.x_true, + "x_meas": printer.x_meas, + }, + "meta": { + "dt_s": dt_chunk, + "t_s": sim_time_s, + "d_to_stride": d_to_stride, + "d_to_flip": d_to_flip, + "event": ("flip" if actually_flipped else "stride"), + "sensor_at_event": int(z1) if is_discrete else float(z1), + }, + } + logger.append(rec) + + # Session summary state + last_sensor = int(z1) if is_discrete else 0 + oo = out.get("output", {}) + last_sensor_ui = oo.get("sensor_ui", None) + last_flowguard = oo.get("flowguard", {"trigger": ""}) + + auto = oo.get("autotune", {}) + if auto.get("rd") is not None: + last_autotune_rd = auto["rd"] + print(f"AUTOTUNE: rd: {auto['rd']:.4f}, reason: {auto.get('note')}") + elif auto.get("note") and args.log_debug: + print(f"DEBUG: {auto['note']}") + + # FlowGuard trip? + fg = oo.get("flowguard", {"trigger": ""}) + trip_kind = fg.get("trigger") + if trip_kind: + trip_kind = trip_kind.upper() + print(f"FlowGuard trip; {trip_kind}. Stopping simulation.") + ctrl.flowguard.reset() # Allow continuation after trigger + break + + # Reset stride from this instant after an event + stride_left = _new_stride_len(remaining - chunk_abs) + else: + # No controller update on non-event chunk + stride_left = max(0.0, stride_left - chunk_abs) + + # Consume distance + remaining -= chunk_abs + + print(f"Simulation complete. Current RD={ctrl.rd_current:.4f}.") + print("Type 'plot' to save a plot, or 'display' to open a window.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + # Event-driven ping-pong: "inout [ []]" + if line.startswith("inout "): + parts = line.split() + if len(parts) not in (3, 4, 5): + print("Usage: inout [ []]") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + try: + move_mm = float(parts[1]) + iters = int(parts[2]) + v_mm_s = float(parts[3]) if len(parts) >= 4 else None + rd_true = float(parts[4]) if len(parts) == 5 else None + except ValueError: + print("Bad numeric values.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + if iters <= 0 or abs(move_mm) < 1e-12: + print("Nothing to do (iters<=0 or move too small).") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # Defaults + default_speed = max(1e-9, abs(args.stride_mm) / max(1e-9, default_dt_s)) + v = abs(v_mm_s) if (v_mm_s is not None and abs(v_mm_s) > 1e-12) else default_speed + if rd_true is not None: + printer.set_extruder_rd_true(rd_true) + + stride = max(1e-9, abs(args.stride_mm)) + eps = 1e-12 + is_discrete = cfg.sensor_type in ("CO", "TO", "D") + norm_clip = max(1e-9, cfg.buffer_max_range_mm / cfg.buffer_range_mm) + + def _read_sensor(): + raw = printer.measure() + return int(raw) if is_discrete else float(raw) + + def _next_flip_distance(sensor_type: str, z0: int, x: float, g: float, s: float): + if sensor_type == "P" or abs(g) <= 1e-16: + return math.inf, None + thr_on, thr_off = printer.get_switch_thresholds() + xvel_pos = (g * s) > 0.0 + targets = [] + if sensor_type == "CO": + if z0 == 0 and xvel_pos: targets.append(+thr_on) + elif z0 == 1 and not xvel_pos: targets.append(+thr_off) + elif sensor_type == "TO": + if z0 == 0 and not xvel_pos: targets.append(-thr_on) + elif z0 == -1 and xvel_pos: targets.append(-thr_off) + elif sensor_type == "D": + if z0 == 0: + targets.append(+thr_on if xvel_pos else -thr_on) + elif z0 == 1 and not xvel_pos: + targets.append(+thr_off) + elif z0 == -1 and xvel_pos: + targets.append(-thr_off) + + lag_norm = 0.0 + if targets and printer.chaos > 1e-12: + lag_mm_max = min(cfg.buffer_max_range_mm, float(printer.chaos) * cfg.buffer_max_range_mm) + lag_norm = (2.0 / cfg.buffer_range_mm) * (random.random() * lag_mm_max) + + best_d = math.inf + best_anchor = None + for anchor in targets: + x_target = anchor + (lag_norm if xvel_pos else -lag_norm) + x_target = max(-norm_clip + 1e-12, min(norm_clip - 1e-12, x_target)) + d_needed = (x_target - x) / g + if d_needed * s > 1e-12: + d_abs = abs(d_needed) + if d_abs < best_d: + best_d = d_abs + best_anchor = anchor + return best_d, best_anchor + + def _d_to_stride_net(net_since_evt: float, s: float) -> float: + """ + Distance (>=0) along current commanded direction s (+1/-1) + until |net_since_evt + s*d| >= stride. If we move opposite the + current net, abs(net) shrinks so the threshold is unreachable. + """ + a = abs(net_since_evt) + if a >= stride - 1e-12: + return 0.0 + if abs(net_since_evt) <= 1e-12: + return stride + if math.copysign(1.0, net_since_evt) == math.copysign(1.0, s): + return max(0.0, stride - a) + return math.inf + + print( + f"Running inout: move={move_mm:.3f}mm, iters={iters}, " + f"speed={v:.3f}mm/s | stride={stride:.3f}mm | " + f"printer_RD_true={printer.extruder_rd_true:.4f}mm" + ) + + emitted = 0 + net_since_event = 0.0 # signed net motion since last emitted event + + # Alternate +move, then -move, repeated + for rep in range(iters): + for phase_sign in (+1.0, -1.0): + seg_len = abs(move_mm) + sgn = math.copysign(1.0, phase_sign * (move_mm if move_mm != 0 else 1.0)) + remaining = seg_len + + while remaining > eps: + # PRE-MOVE read + z0 = _read_sensor() + + # RD in effect for this chunk + rd_in_effect = getattr(ctrl, "rd_prev", ctrl.rd_current) + + # Motion gain and direction + g = printer.gain_per_mm(rd_in_effect) + s = 1.0 if sgn > 0 else -1.0 + + # Predict next flip and next stride crossing of |net| + x_now = printer.x_true + if cfg.sensor_type == "P": + d_to_flip, _ = math.inf, None + else: + d_to_flip, _ = _next_flip_distance(cfg.sensor_type, int(z0), x_now, g, s) + d_to_stride = _d_to_stride_net(net_since_event, s) + + # Advance to the earliest of (segment end, flip, stride) + chunk_abs = min(remaining, d_to_flip, d_to_stride) + d_chunk = sgn * chunk_abs + dt_chunk = (chunk_abs / v) if v > 1e-12 else default_dt_s + sim_time_s = printer.advance_time(dt_chunk) + + # Physical spring evolution with rd_in_effect + printer.advance_physics(rd_in_effect, d_chunk) + + # Read AFTER motion + z1 = _read_sensor() + + # Event classification + if is_discrete: + actually_flipped = (int(z1) != int(z0)) + else: + actually_flipped = False + predicted_flip = abs(chunk_abs - d_to_flip) <= 1e-12 + hit_stride = (d_to_stride < math.inf) and (abs(chunk_abs - d_to_stride) <= 1e-12) + + emit = (actually_flipped and not args.stride_only) or hit_stride + if emit: + label = "flip" if actually_flipped else "stride" + out = ctrl.update(sim_time_s, d_chunk, z1, simulation=True) + + rec = { + **out, + "truth": { + "rd_true": printer.extruder_rd_true, + "spring_mm": printer.spring_mm(), + "x_true": printer.x_true, + "x_meas": printer.x_meas, + }, + "meta": { + "dt_s": dt_chunk, + "t_s": sim_time_s, + "event": label, + "phase": ("in" if sgn > 0 else "out"), + "sensor_at_event": int(z1) if is_discrete else float(z1), + }, + } + logger.append(rec) + emitted += 1 + + # Session summary state + last_sensor = int(z1) if is_discrete else float(z1) + oo = out.get("output", {}) + last_sensor_ui = oo.get("sensor_ui", None) + last_flowguard = oo.get("flowguard", {"trigger": ""}) + + auto = oo.get("autotune", {}) + if auto.get("rd") is not None: + last_autotune_rd = auto["rd"] + print(f"AUTOTUNE: rd: {auto['rd']:.4f}, reason: {auto.get('note')}") + elif auto.get("note") and args.log_debug: + print(f"DEBUG: {auto['note']}") + + # FlowGuard trip? + fg = oo.get("flowguard", {"trigger": ""}) + trip_kind = fg.get("trigger") + if trip_kind: + trip_kind = trip_kind.upper() + print(f"FlowGuard trip; {trip_kind}. Stopping inout.") + ctrl.flowguard.reset() # Allow continuation after trigger + remaining = 0.0 + rep = iters # break outer loops + break + + # Reset net tracker AFTER an emitted event + net_since_event = 0.0 + else: + # Silent advance: accumulate signed net, no controller update + net_since_event += d_chunk + + # Consume portion of the segment + remaining -= chunk_abs + # end while + # end for phase + # end for iters + + print(f"InOut complete. Emitted {emitted} event(s). Current RD={ctrl.rd_current:.4f}.") + if emitted == 0: + print("Note: No stride/flip events occurred (net never reached stride and no sensor flips).") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # ---------------------------------------------------------------------- + # Manual update: "t|tick []" + if line.startswith("t ") or line.startswith("tick "): + parts = line.split() + if parts[0] in ("t", "tick"): + parts = parts[1:] + + if len(parts) == 0: + print("Usage: t []") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + if len(parts) > 2: + print("Too many parameters. Usage: t []") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + try: + d_ext = float(parts[0]) + except ValueError: + print("Bad numeric value for d_ext_mm.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # Sensor handling (pre-move) + if len(parts) == 1 or parts[1].strip().lower() == "auto": + z = printer.measure() + else: + if cfg.sensor_type == "P": + try: + z = float(parts[1]) + except ValueError: + print("Bad sensor value. Expect float in [-1,1].") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + else: + try: + z = int(parts[1]) + except ValueError: + print("Bad sensor value. Expect integer.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + allowed = {-1, 0, 1} if cfg.sensor_type == "D" else ({0, 1} if cfg.sensor_type == "CO" else {-1, 0}) + if z not in allowed: + if cfg.sensor_type == "D": + print("Discrete sensor (D) must be -1, 0, or 1.") + elif cfg.sensor_type == "CO": + print("Compression-only sensor (CO) must be 0 or 1.") + else: + print("Tension-only sensor (TO) must be -1 or 0.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # RD that was in effect during this tick (before controller updates) + rd_in_effect = getattr(ctrl, "rd_prev", ctrl.rd_current) + + # Advance time and update controller + sim_time_s = printer.advance_time(default_dt_s) + out = ctrl.update(sim_time_s, d_ext, z, simulation=True) + + # Physical evolution using rd_in_effect for this motion + printer.advance_physics(rd_in_effect, d_ext) + + rec = { + **out, + "truth": { + "rd_true": printer.extruder_rd_true, + "spring_mm": printer.spring_mm(), + "x_true": printer.x_true, + "x_meas": printer.x_meas, + }, + "meta": { + "dt_s": default_dt_s, + "t_s": sim_time_s, + }, + } + logger.append(rec) + + last_sensor = z + oo = out["output"] + last_sensor_ui = oo["sensor_ui"] + last_flowguard = oo["flowguard"] + + print(f"RD={oo['rd_current']:.4f} | x={oo['x_est']:.3f} | c={oo['c_est']:.4f} | " + f"sensor_ui={oo['sensor_ui']:.3f} | Bowden/Buffer spring={printer.spring_mm():.3f}mm | " + f"FlowGuard: {oo['flowguard']} | Autotune: {oo['autotune']}") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + continue + + # Fallback + print("Unknown command. Type 'help' for usage.") + _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) + +# ------------------------------- Main ---------------------------------- + +if __name__ == "__main__": + _run_cli() + From 8dae1a32fad64bcf9607dc2aedd6b90cab7c07b0 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Tue, 24 Mar 2026 15:33:48 +0000 Subject: [PATCH 02/19] wip --- AGENTS.md | 198 ++++++ docs/Generated-filament_manager_plan.md | 122 ++++ klippy/extras/Generated-filament_manager.cfg | 37 ++ klippy/extras/Generated-filament_manager.py | 624 +++++++++++++++++++ klippy/extras/mmu_extruder.py | 0 klippy/extras/old-filament-motions.py | 184 ++++++ klippy/extras/toolhead_move_patterns.py | 67 ++ 7 files changed, 1232 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/Generated-filament_manager_plan.md create mode 100644 klippy/extras/Generated-filament_manager.cfg create mode 100644 klippy/extras/Generated-filament_manager.py create mode 100644 klippy/extras/mmu_extruder.py create mode 100644 klippy/extras/old-filament-motions.py create mode 100644 klippy/extras/toolhead_move_patterns.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..c0038fcae609 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,198 @@ +# AGENTS.md - Klipper Development Guide + +This file provides guidance for AI agents working on the Klipper 3D printer firmware. + +## Project Overview + +Klipper is a 3D printer firmware combining a Raspberry Pi (host) with microcontrollers. This is a Blocks fork with additional modules (belay, filament cutter, load/unload, bucket, bed boundaries). + +## Directory Structure + +| Directory | Purpose | +|-----------|---------| +| `klippy/` | Host-side Python code (runs on Raspberry Pi) | +| `klippy/extras/` | Optional modules (~100+ plugins) | +| `klippy/kinematics/` | Printer motion models (cartesian, delta, corexy, etc.) | +| `src/` | Microcontroller firmware in C | +| `config/` | Printer board configurations (150+ boards) | +| `scripts/` | Build/flash/utilities scripts | +| `test/klippy/` | Test configuration files | +| `docs/` | Documentation | +| `lib/` | External dependencies (CMSIS, HAL, SDKs) | + +## Build Commands + +### Firmware Build +```bash +# Configure (select board via menu) +make menuconfig + +# Build firmware +make + +# Clean build artifacts +make clean + +# Full clean (removes config too) +make distclean + +# Verbose build (see actual commands) +make V=1 +``` + +### Running Tests + +```bash +# Run all tests +python3 scripts/test_klippy.py test/klippy/*.test + +# Run a single test (verbose) +python3 scripts/test_klippy.py -v test/klippy/commands.test + +# Run single test, keep temp files +python3 scripts/test_klippy.py -k test/klippy/commands.test +``` + +Test files are `.test` files in `test/klippy/` that reference: +- `DICTIONARY` - MCU dictionary file +- `CONFIG` - Printer config file +- G-code commands to execute + +## Code Style - Python (klippy/) + +### General Guidelines +- **Python 2/3 compatible** (shebang uses `python2`, runs on Python 3) +- No type annotations (keep compatibility) +- 4-space indentation +- Maximum line length: ~80-100 characters (flexible) +- Use `logging` module for logging, not print statements + +### Imports +```python +# Standard library first, then third-party, then local +import os +import re +import logging +import collections + +import util, reactor, queuelogger +import gcode, configfile, pins +``` + +### Naming Conventions +- **Classes**: CamelCase (e.g., `class Printer:`, `class GCodeDispatch:`) +- **Functions/methods**: snake_case (e.g., `def get_position():`, `def setup_registers():`) +- **Constants**: UPPER_CASE (e.g., `MAX_SPEED = 1000`) +- **Private methods**: prefix with underscore (e.g., `def _connect(self):`) + +### Error Handling +```python +# Configuration errors +raise self.config_error("Error message: %s" % (value,)) + +# G-code command errors +raise gcode.CommandError("Error processing command") + +# Use logging for errors +logging.error("Error message: %s" % (detail,)) +logging.info("Informational message") +logging.debug("Debug details") +``` + +### Class Structure Pattern +```python +class Printer: + config_error = configfile.error + command_error = gcode.CommandError + + def __init__(self, main_reactor, bglogger, start_args): + self.bglogger = bglogger + self.start_args = start_args + self.reactor = main_reactor + + def get_reactor(self): + return self.reactor +``` + +### Config File Pattern +```python +class MyModule: + def __init__(self, config): + self.printer = config.get_printer() + self.name = config.get_name() + # Register handlers + self.printer.register_event_handler("klippy:ready", self.handle_ready) +``` + +## Code Style - C (src/) + +### General Guidelines +- **C11 standard** with GNU extensions (`-std=gnu11`) +- 4-space indentation +- K&R brace style +- Comment style: `// Single line` or `/* Multi-line */` + +### Includes (order matters) +```c +#include "autoconf.h" // CONFIG_* - generated +#include "basecmd.h" // oid_alloc +#include "board/gpio.h" // gpio_out_write +#include "command.h" // DECL_COMMAND +#include "sched.h" // struct timer +``` + +### Naming Conventions +- **Structs**: lowercase with underscores (e.g., `struct stepper_move`) +- **Enums**: UPPER_CASE with prefix (e.g., `enum { MF_DIR=1<<0 }`) +- **Functions**: snake_case (e.g., `stepper_load_next()`) +- **Macros**: UPPER_CASE (e.g., `#define HAVE_EDGE_OPTIMIZATION 1`) + +### Code Patterns +```c +// Struct definition +struct stepper { + struct timer time; + uint32_t interval; + struct gpio_out step_pin; +}; + +// Function with error handling +static uint_fast8_t +stepper_load_next(struct stepper *s) +{ + if (move_queue_empty(&s->mq)) { + s->count = 0; + return SF_DONE; + } + // ... implementation +} +``` + +## Configuration System + +Uses Kconfig (Linux kernel style): +- `make menuconfig` opens interactive configurator +- Board configs in `config/` directory +- Creates `.config` file and `out/autoconf.h` + +## VSCode Settings + +Project has `.vscode/settings.json` with pylint: +```json +{ + "pylint.args": ["--disable=C0115", "--disable=R0902"] +} +``` +- C0115: Missing class docstring +- R0902: Too many instance attributes + +## Key Files to Know + +- `klippy/klippy.py` - Main entry point +- `klippy/gcode.py` - G-code parser +- `klippy/toolhead.py` - Motion planning +- `klippy/configfile.py` - Config parsing +- `klippy/mcu.py` - MCU communication +- `src/sched.c` - Task scheduler +- `src/command.c` - Command protocol +- `src/stepper.c` - Stepper motor control diff --git a/docs/Generated-filament_manager_plan.md b/docs/Generated-filament_manager_plan.md new file mode 100644 index 000000000000..26681ea7e749 --- /dev/null +++ b/docs/Generated-filament_manager_plan.md @@ -0,0 +1,122 @@ +# Filament Manager Implementation Plan + +## Discussion Summary + +### 1. Enum vs Literal for States +- Current code uses `typing.Literal["loading", "loaded", "unloaded", "unloading", "unknown"]` +- **Decision**: Use `enum.Enum` (already implemented as `FilamentStates`) +- Benefits: Proper values for comparison, IDE autocomplete, no typo bugs + +### 2. Temperature Constants +- Current code has: `PLA_TEMPERATURE`, `PETG_TEMPERATURE`, `ABS_TEMPERATURE`, `NYLON_TEMPERATURE`, `DEFAULT_TEMPERATURE` +- **Decision**: Keep as constants (current approach is fine) +- Reasoning: They're truly constants, no behavior, Python idiom + +--- + +## Requirements + +1. Multiple extruders support - load/unload each head independently +2. Sensors (switch sensors, filament_motion_sensor, cutter_sensor) for detecting filament state +3. Configuration per extruder with optional sensors +4. Status via `get_status(eventtime)`: loading/unloading/loaded/unloaded/unknown +5. Generic sensor support - any switch sensor can be used +6. Purge at end of load (move to bucket position) +7. Timeout fallback if no sensors +8. State persistence via variables.py + +--- + +## Key Design Decisions + +### Temperature +- **Option B**: G-code parameter `LOAD_FILAMENT EXTRUDER=extruder FILAMENT=PLA` +- Use `FILAMENT_TEMPERATURES` dict lookup + +### Bucket Integration +- Use existing `bucket.py` functionality +- Call `self.bucket.move_to_bucket()` during purge + +### Sensors Configuration +```ini +[filament_manager extruder] +sensors: my_switch_sensor, my_motion_sensor, cutter +``` + +### Initial State +- Default to `unknown` +- Load from `save_variables` on startup +- Save state after each load/unload operation + +### Error Handling +- Use `self.printer.command_error("")` +- Stop procedure, don't continue + +--- + +## Class Structure + +``` +FilamentManager (main config + G-code commands) + | + +-- FilamentMotions (per-extruder controller) + | + +-- SensorChecker (generic sensor wrapper) + +-- ExtruderMotions (heating, movement) +``` + +--- + +## Events Emitted + +- `filament_manager:loading` +- `filament_manager:loaded` +- `filament_manager:unloading` +- `filament_manager:unloaded` +- `filament_manager:error` + +--- + +## G-code Commands + +```bash +LOAD_FILAMENT EXTRUDER=extruder FILAMENT=PLA +UNLOAD_FILAMENT EXTRUDER=extruder +QUERY_FILAMENT [EXTRUDER=extruder] +``` + +--- + +## Configuration Example + +```ini +[filament_manager] +default_timeout: 30 +purge_count: 3 +purge_length: 5 +purge_speed: 5 +load_speed: 10 +unload_speed: 10 +travel_speed: 100 +save_variables: True + +[filament_manager extruder] +sensors: my_switch_sensor, my_motion_sensor +load_timeout: 20 +unload_timeout: 15 +``` + +--- + +## Implementation Issues Fixed + +| Issue | Fix | +|-------|-----| +| `typing.Literal` used as values | Use `FilamentStates` enum properly | +| Incomplete load/unload methods | Fully implemented | +| No state persistence | Added `_save_state()` / `_load_saved_state()` | +| No bucket integration | Added `self.bucket.move_to_bucket()` on purge | +| No filament type/temperature | Added `FILAMENT_TEMPERATURES` dict | +| No purge logic | Added `_purge()` timer and purge_count | +| Broken sensor callbacks | Fixed trigger handlers | +| Incomplete status | `get_status()` returns proper dict | diff --git a/klippy/extras/Generated-filament_manager.cfg b/klippy/extras/Generated-filament_manager.cfg new file mode 100644 index 000000000000..ad7b94f63c5a --- /dev/null +++ b/klippy/extras/Generated-filament_manager.cfg @@ -0,0 +1,37 @@ +# Example configuration for filament_manager +# Save this to your printer config file + +[filament_manager] +# Global settings +# Timeouts (in seconds) +default_timeout: 30 + +# Purge settings (performed after successful load) +purge_count: 3 +purge_length: 5 +purge_speed: 5 +purge_interval: 2.0 + +# Load/Unload speeds (mm/s) +load_speed: 10 +unload_speed: 10 + +# Travel speed (mm/s) +travel_speed: 100 + +# Save filament state to variables file (persists across restarts) +save_variables: True + +# Per-extruder settings (optional - will use globals if not specified) +# Uncomment and modify as needed: + +# [filament_manager extruder] +# sensors: my_switch_sensor, my_motion_sensor, cutter +# load_timeout: 20 +# unload_timeout: 15 +# load_speed: 8 +# unload_speed: 12 +# purge_count: 5 +# purge_length: 3 +# purge_speed: 3 +# save_variables: True diff --git a/klippy/extras/Generated-filament_manager.py b/klippy/extras/Generated-filament_manager.py new file mode 100644 index 000000000000..891f4f1b1341 --- /dev/null +++ b/klippy/extras/Generated-filament_manager.py @@ -0,0 +1,624 @@ +import enum +import logging +import typing +from functools import partial + +PLA_TEMPERATURE = 230 +PETG_TEMPERATURE = 240 +ABS_TEMPERATURE = 250 +NYLON_TEMPERATURE = 270 +DEFAULT_TEMPERATURE = 250 + +FILAMENT_TEMPERATURES = { + "PLA": PLA_TEMPERATURE, + "PETG": PETG_TEMPERATURE, + "ABS": ABS_TEMPERATURE, + "NYLON": NYLON_TEMPERATURE, +} + + +class FilamentStates(enum.Enum): + LOADING = "loading" + LOADED = "loaded" + UNLOADED = "unloaded" + UNLOADING = "unloading" + UNKNOWN = "unknown" + + +class SensorChecker: + def __init__(self, config) -> None: + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.is_enabled = False + self.sensor = None + self.callback = None + self.trigger_state = False + self.current_state = False + self.check_interval = 1.5 + self.last_check_time = 0 + self.min_event_systime = self.reactor.NEVER + self.event_delay = 0.5 + self.check_timer = self.reactor.register_timer( + self.verify_sensor, self.reactor.NEVER + ) + self.printer.register_event_handler("klippy:ready", self.handle_ready) + + def handle_ready(self) -> None: + self.min_event_systime = self.reactor.monotonic() + 2.0 + + def toggle_check(self) -> None: + if not self.sensor: + return + self.is_enabled = not self.is_enabled + if self.is_enabled and self.sensor: + self.reactor.update_timer(self.check_timer, self.reactor.NOW) + return + self.reactor.update_timer(self.check_timer, self.reactor.NEVER) + + def trigger_check(self) -> None: + if self.sensor and self.callback: + self.reactor.register_callback(self.callback) + + def register_sensor(self, sensor_type: str, sensor_name: str): + self.sensor = self.printer.lookup_object(f"{sensor_type} {sensor_name}", None) + if not self.sensor: + raise self.printer.config_error( + f"Unknown Sensor {sensor_type} {sensor_name}" + ) + + def set_check_interval(self, interval: float) -> None: + self.check_interval = max(0.1, interval) + + def register_callback( + self, callback: typing.Callable[..., None], trigger: bool + ) -> None: + self.trigger_state = trigger + self.callback = callback + + def verify_sensor(self, eventtime: float) -> float: + if not self.is_enabled: + return self.reactor.NEVER + if not self.sensor: + self.printer.command_error(f"Sensor check failed") + return self.reactor.NEVER + status = self.sensor.get_status(eventtime) + filament_present = status.get("filament_detected", self.trigger_state) + if (filament_present != self.current_state) and ( + filament_present == self.trigger_state + ): + if eventtime >= self.min_event_systime: + self.current_state = filament_present + self.min_event_systime = self.reactor.NEVER + self.reactor.register_callback(self._handle_trigger) + self.last_check_time = eventtime + return eventtime + self.check_interval + + def _handle_trigger(self): + completion = self.reactor.register_callback(self.callback) + self.min_event_systime = self.reactor.monotonic() + self.event_delay + return completion.wait() + + def active(self) -> bool: + return self.is_enabled + + +class ExtruderMotions: + def __init__(self, config, extruder, name) -> None: + self.printer = config.get_printer() + self.name = name + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object("gcode") + self.pheaters = self.extruder_heater = None + self.travel_speed = config.getfloat( + "travel_speed", default=100.0, minval=50.0, maxval=500.0 + ) + self.extruder_speed = config.getfloat( + "extruder_speed", default=10.0, minval=2.0, maxval=50.0 + ) + self.printer.register_event_handler("klippy:ready", self.handle_ready) + self.printer.register_event_handler("klippy:connect", self.handle_connect) + self.extruder = extruder + self.old_extruder = None + self.toolhead = None + + def handle_connect(self) -> None: + self.toolhead = self.printer.lookup_object("toolhead") + + def handle_ready(self) -> None: + if self.toolhead: + self.old_extruder = self.toolhead.get_extruder() + + def _force_activate(self) -> None: + if not self.toolhead or not self.extruder: + return + if self.extruder != self.old_extruder: + self.old_extruder = self.toolhead.get_extruder() + self.toolhead.flush_step_generation() + last_position = self.extruder.extruder_stepper.find_past_position( + self.reactor.monotonic() + ) + self.toolhead.set_extruder(self.extruder, last_position) + self.printer.send_event("extruder:activate_extruder") + logging.info("Activated extruder %s", str(self.extruder.get_name())) + + def heat(self, temp: int, threshold: float = 0.1, wait: bool = False) -> None: + if not self.extruder: + return + eventtime = self.reactor.monotonic() + heater = self.extruder.get_heater() + if ( + (temp * (1 - threshold)) + <= heater.get_temp(eventtime)[0] + <= (temp * (1 + threshold)) + ): + heater.set_temp(temp) + while not self.printer.is_shutdown() and wait: + heater_temp, target_temp = heater.get_temp(eventtime) + if ( + (target_temp * (1 - threshold)) + <= heater_temp + <= (target_temp * (1 + threshold)) + ): + return + eventtime = self.reactor.pause(eventtime + 1.0) + + def move( + self, distance: float = 10.0, speed: float = 10.0, wait: bool = True + ) -> None: + if not self.extruder or not self.toolhead: + return + extruder_heater = self.extruder.get_heater() + if not extruder_heater.can_extrude: + raise self.printer.command_error("Extruder below minimum temperature") + self._force_activate() + eventtime = self.reactor.monotonic() + force_move = self.printer.lookup_object("force_move") + mcu = self.printer.lookup_object("mcu") + est_print_time = mcu.estimated_print_time(eventtime) + prev_position = self.extruder.find_past_position(est_print_time) + npos = prev_position + distance + force_move.manual_move( + self.extruder.extruder_stepper.stepper, + npos, + distance, + speed, + ) + if wait: + self.toolhead.wait_moves() + + +class FilamentMotions: + def __init__(self, config, name, extruder, manager): + self.config = config + self.printer = config.get_printer() + self.name = name + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object("gcode") + self.manager = manager + self.toolhead = None + self.bucket = None + self.extruder_motion = ExtruderMotions(config, extruder, name) + self.sensor_checkers: dict[str, SensorChecker] = {} + self.state: FilamentStates = FilamentStates.UNKNOWN + self.timeout = config.getint("timeout", default=30, minval=10, maxval=1000) + self.load_timeout = config.getint( + "load_timeout", default=self.timeout, minval=10, maxval=1000 + ) + self.unload_timeout = config.getint( + "unload_timeout", default=self.timeout, minval=10, maxval=1000 + ) + self.sensors = config.getlist("sensors", None) + self.load_speed = config.getfloat( + "load_speed", default=10.0, minval=1.0, maxval=50.0 + ) + self.unload_speed = config.getfloat( + "unload_speed", default=10.0, minval=1.0, maxval=50.0 + ) + self.purge_count = config.getint("purge_count", default=3, minval=0, maxval=20) + self.purge_length = config.getfloat( + "purge_length", default=5.0, minval=0.5, maxval=50.0 + ) + self.purge_speed = config.getfloat( + "purge_speed", default=5.0, minval=1.0, maxval=50.0 + ) + self.purge_interval = config.getfloat( + "purge_interval", default=2.0, minval=0.5, maxval=10.0 + ) + self.extrude_count = 0 + self.current_purge_index = 0 + self.operation_temp = DEFAULT_TEMPERATURE + self.save_variables = config.getboolean("save_variables", True) + + self.unextrude_timer = self.reactor.register_timer( + self._unextrude, self.reactor.NEVER + ) + self.extrude_timer = self.reactor.register_timer( + self._extrude, self.reactor.NEVER + ) + self.purge_timer = self.reactor.register_timer(self._purge, self.reactor.NEVER) + + self.printer.register_event_handler("klippy:ready", self.handle_ready) + self.printer.register_event_handler("klippy:connect", self.handle_connect) + + def handle_connect(self) -> None: + self.toolhead = self.printer.lookup_object("toolhead") + self.bucket = self.printer.lookup_object("bucket", None) + self._load_saved_state() + self._register_sensors() + + def handle_ready(self) -> None: + pass + + def _load_saved_state(self) -> None: + if not self.save_variables: + return + try: + save_vars = self.printer.lookup_object("save_variables") + if save_vars: + variables = save_vars.get_status(0).get("variables", {}) + saved_state = variables.get(f"filament_state_{self.name}") + if saved_state: + try: + self.state = FilamentStates(saved_state) + logging.info( + f"[FilamentManager] Loaded saved state for {self.name}: {self.state.value}" + ) + except ValueError: + logging.warning( + f"[FilamentManager] Invalid saved state for {self.name}: {saved_state}" + ) + except Exception as e: + logging.debug(f"[FilamentManager] Could not load saved state: {e}") + + def _save_state(self) -> None: + if not self.save_variables: + return + try: + self.gcode.run_script_from_command( + f'SAVE_VARIABLE VARIABLE=filament_state_{self.name} VALUE="{self.state.value}"' + ) + except Exception as e: + logging.warning(f"[FilamentManager] Could not save state: {e}") + + def _register_sensors(self) -> None: + if not self.sensors: + return + for sensor_name in self.sensors: + sensor_name = sensor_name.strip() + if not sensor_name: + continue + for sensor_type in [ + "filament_switch_sensor", + "filament_motion_sensor", + "cutter_sensor", + ]: + sensor = self.printer.lookup_object( + f"{sensor_type} {sensor_name}", None + ) + if sensor: + logging.info( + f"[FilamentManager] Registered {sensor_type} '{sensor_name}' for {self.name}" + ) + break + + def _get_temperature(self, filament_type: str) -> int: + return FILAMENT_TEMPERATURES.get(filament_type.upper(), DEFAULT_TEMPERATURE) + + def _enable_sensors(self) -> None: + for checker in self.sensor_checkers.values(): + checker.toggle_check() + + def _disable_sensors(self) -> None: + for checker in self.sensor_checkers.values(): + if checker.active(): + checker.toggle_check() + + def _unextrude(self, eventtime: float) -> float: + if self.extrude_count >= self.unload_timeout: + self._cleanup_unload(error=False) + return self.reactor.NEVER + self.extrude_count += 1 + self.extruder_motion.move(-10, self.unload_speed, wait=False) + return eventtime + float(10 / self.unload_speed) + + def _extrude(self, eventtime: float) -> float: + if self.extrude_count >= self.load_timeout: + self.reactor.update_timer(self.extrude_timer, self.reactor.NEVER) + self._start_purge() + return self.reactor.NEVER + self.extrude_count += 1 + self.extruder_motion.move(10, self.load_speed, wait=False) + return eventtime + float(10 / self.load_speed) + + def _purge(self, eventtime: float) -> float: + if self.current_purge_index >= self.purge_count: + self._cleanup_load(error=False) + return self.reactor.NEVER + self.current_purge_index += 1 + self.extruder_motion.move(self.purge_length, self.purge_speed, wait=True) + self.gcode.respond_info( + f"[FilamentManager] Purge {self.current_purge_index}/{self.purge_count}" + ) + return eventtime + self.purge_interval + + def _start_purge(self) -> None: + if self.purge_count <= 0: + self._cleanup_load(error=False) + return + if self.bucket: + self.bucket.move_to_bucket() + if self.toolhead: + self.toolhead.wait_moves() + self.current_purge_index = 0 + self.reactor.update_timer(self.purge_timer, self.reactor.NOW) + + def _cleanup_load(self, error: bool = False) -> None: + self.reactor.update_timer(self.extrude_timer, self.reactor.NEVER) + self.reactor.update_timer(self.purge_timer, self.reactor.NEVER) + self._disable_sensors() + if error: + self.state = FilamentStates.UNKNOWN + self.printer.send_event("filament_manager:error") + else: + self.state = FilamentStates.LOADED + self.printer.send_event("filament_manager:loaded") + self._save_state() + self.gcode.respond_info( + f"[FilamentManager] Load {'failed' if error else 'complete'} for {self.name}" + ) + + def _cleanup_unload(self, error: bool = False) -> None: + self.reactor.update_timer(self.unextrude_timer, self.reactor.NEVER) + self._disable_sensors() + if error: + self.state = FilamentStates.UNKNOWN + self.printer.send_event("filament_manager:error") + else: + self.state = FilamentStates.UNLOADED + self.printer.send_event("filament_manager:unloaded") + self._save_state() + self.gcode.respond_info( + f"[FilamentManager] Unload {'failed' if error else 'complete'} for {self.name}" + ) + + def _handle_load_sensor_trigger(self) -> None: + if self.state != FilamentStates.LOADING: + return + self.reactor.update_timer(self.extrude_timer, self.reactor.NEVER) + self._start_purge() + + def _handle_unload_sensor_trigger(self) -> None: + if self.state != FilamentStates.UNLOADING: + return + self.reactor.update_timer(self.unextrude_timer, self.reactor.NEVER) + self._cleanup_unload(error=False) + + def load(self, filament_type: str = "PLA") -> None: + if self.state not in [FilamentStates.UNLOADED, FilamentStates.UNKNOWN]: + raise self.printer.command_error( + f"Cannot load filament on {self.name}: current state is {self.state.value}" + ) + if not self.toolhead: + raise self.printer.command_error(f"Toolhead not available for {self.name}") + + self.state = FilamentStates.LOADING + self.printer.send_event("filament_manager:loading") + self.extrude_count = 0 + self.operation_temp = self._get_temperature(filament_type) + + self._disable_sensors() + self.sensor_checkers.clear() + + for sensor_name in self.sensors or []: + checker = SensorChecker(self.config) + try: + checker.register_sensor("filament_switch_sensor", sensor_name) + checker.set_check_interval(1.0) + checker.register_callback(self._handle_load_sensor_trigger, True) + self.sensor_checkers[f"load_{sensor_name}"] = checker + except Exception: + try: + checker.register_sensor("filament_motion_sensor", sensor_name) + checker.set_check_interval(1.0) + checker.register_callback(self._handle_load_sensor_trigger, True) + self.sensor_checkers[f"load_{sensor_name}"] = checker + except Exception: + try: + checker.register_sensor("cutter_sensor", sensor_name) + checker.set_check_interval(1.0) + checker.register_callback( + self._handle_load_sensor_trigger, True + ) + self.sensor_checkers[f"load_{sensor_name}"] = checker + except Exception as e: + logging.warning( + f"[FilamentManager] Could not register sensor {sensor_name}: {e}" + ) + + self.extruder_motion.heat(self.operation_temp, wait=True) + + self._enable_sensors() + self.reactor.update_timer(self.extrude_timer, self.reactor.NOW) + self.gcode.respond_info( + f"[FilamentManager] Loading {filament_type} at {self.operation_temp}C on {self.name}" + ) + + def unload(self) -> None: + if self.state not in [FilamentStates.LOADED, FilamentStates.UNKNOWN]: + raise self.printer.command_error( + f"Cannot unload filament on {self.name}: current state is {self.state.value}" + ) + if not self.toolhead: + raise self.printer.command_error(f"Toolhead not available for {self.name}") + + self.state = FilamentStates.UNLOADING + self.printer.send_event("filament_manager:unloading") + self.extrude_count = 0 + + self._disable_sensors() + self.sensor_checkers.clear() + + for sensor_name in self.sensors or []: + checker = SensorChecker(self.config) + try: + checker.register_sensor("filament_switch_sensor", sensor_name) + checker.set_check_interval(1.0) + checker.register_callback(self._handle_unload_sensor_trigger, False) + self.sensor_checkers[f"unload_{sensor_name}"] = checker + except Exception: + try: + checker.register_sensor("filament_motion_sensor", sensor_name) + checker.set_check_interval(1.0) + checker.register_callback(self._handle_unload_sensor_trigger, False) + self.sensor_checkers[f"unload_{sensor_name}"] = checker + except Exception: + try: + checker.register_sensor("cutter_sensor", sensor_name) + checker.set_check_interval(1.0) + checker.register_callback( + self._handle_unload_sensor_trigger, False + ) + self.sensor_checkers[f"unload_{sensor_name}"] = checker + except Exception as e: + logging.warning( + f"[FilamentManager] Could not register sensor {sensor_name}: {e}" + ) + + self.extruder_motion.heat(self.operation_temp, wait=True) + + self._enable_sensors() + self.reactor.update_timer(self.unextrude_timer, self.reactor.NEVER) + self.gcode.respond_info(f"[FilamentManager] Unloading {self.name}") + + def get_status(self, eventtime: float) -> dict: + return { + "state": self.state.value, + "extruder": self.name, + "loading": self.state == FilamentStates.LOADING, + "unloading": self.state == FilamentStates.UNLOADING, + } + + +class FilamentManager: + def __init__(self, config): + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = None + self.toolhead = self.extruder_objects = self.bucket = None + self.custom_boundary = None + self.config = config + self.motions: dict[str, FilamentMotions] = {} + self.default_timeout = config.getint( + "default_timeout", default=30, minval=10, maxval=1000 + ) + self.purge_count = config.getint("purge_count", default=3, minval=0, maxval=20) + self.purge_length = config.getfloat( + "purge_length", default=5.0, minval=0.5, maxval=50.0 + ) + self.purge_speed = config.getfloat( + "purge_speed", default=5.0, minval=1.0, maxval=50.0 + ) + self.load_speed = config.getfloat( + "load_speed", default=10.0, minval=1.0, maxval=50.0 + ) + self.unload_speed = config.getfloat( + "unload_speed", default=10.0, minval=1.0, maxval=50.0 + ) + self.travel_speed = config.getfloat( + "travel_speed", default=100.0, minval=20.0, maxval=1000.0 + ) + + self.printer.register_event_handler("klippy:ready", self.handle_ready) + self.printer.register_event_handler("klippy:connect", self.handle_connect) + + def handle_connect(self) -> None: + self.toolhead = self.printer.lookup_object("toolhead") + + def handle_ready(self) -> None: + self.gcode = self.printer.lookup_object("gcode") + self.extruder_objects = self.printer.lookup_objects("extruder") + + for name, extruder in self.extruder_objects: + try: + extruder_config = self.config.getsection(f"filament_manager {name}") + except Exception: + extruder_config = self.config + self.motions[name] = FilamentMotions(extruder_config, name, extruder, self) + self.gcode.respond_info(f"[FilamentManager] Registered extruder: {name}") + + self.gcode.register_mux_command( + "LOAD_FILAMENT", + "EXTRUDER", + None, + self.cmd_LOAD_FILAMENT, + desc="Load filament into extruder", + ) + self.gcode.register_mux_command( + "UNLOAD_FILAMENT", + "EXTRUDER", + None, + self.cmd_UNLOAD_FILAMENT, + desc="Unload filament from extruder", + ) + self.gcode.register_command( + "QUERY_FILAMENT", + self.cmd_QUERY_FILAMENT, + desc="Query filament manager status", + ) + + def _get_motion(self, extruder_name: str) -> FilamentMotions: + if extruder_name not in self.motions: + raise self.printer.command_error(f"Unknown extruder: {extruder_name}") + return self.motions[extruder_name] + + def cmd_LOAD_FILAMENT(self, gcmd) -> None: + extruder_name = gcmd.get("EXTRUDER") + filament_type = gcmd.get("FILAMENT", "PLA") + + if not extruder_name and self.toolhead: + extruder_name = self.toolhead.get_extruder().get_name() + + if not extruder_name: + raise self.printer.command_error("No extruder specified") + + motion = self._get_motion(extruder_name) + + self.reactor.register_callback(partial(motion.load, filament_type)) + + def cmd_UNLOAD_FILAMENT(self, gcmd) -> None: + extruder_name = gcmd.get("EXTRUDER") + + if not extruder_name and self.toolhead: + extruder_name = self.toolhead.get_extruder().get_name() + + if not extruder_name: + raise self.printer.command_error("No extruder specified") + + motion = self._get_motion(extruder_name) + + self.reactor.register_callback(motion.unload) + + def cmd_QUERY_FILAMENT(self, gcmd) -> None: + extruder_name = gcmd.get("EXTRUDER") + + if extruder_name: + motion = self._get_motion(extruder_name) + status = motion.get_status(0) + self.gcode.respond_info(f"Filament {status['extruder']}: {status['state']}") + else: + for name, motion in self.motions.items(): + status = motion.get_status(0) + self.gcode.respond_info( + f"Filament {status['extruder']}: {status['state']}" + ) + + def get_status(self, eventtime: float) -> dict: + return { + "extruders": { + name: m.get_status(eventtime) for name, m in self.motions.items() + } + } + + +def load_config(config): + return FilamentManager(config) diff --git a/klippy/extras/mmu_extruder.py b/klippy/extras/mmu_extruder.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/klippy/extras/old-filament-motions.py b/klippy/extras/old-filament-motions.py new file mode 100644 index 000000000000..b63dfa2c5e0b --- /dev/null +++ b/klippy/extras/old-filament-motions.py @@ -0,0 +1,184 @@ +class FilamentMotions: + def __init__(self, config, name, extruder): + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.name = config.get_name().split()[-1] + + self.identifier: str = f"FM-{name}" + + self.extrude_count: int = 0 + + self.emotion: ExtruderMotion = ExtruderMotion(config, extruder, name) + self.sensor_notes: dict[str, SensorChecker] = {} + # self.unextrude_timer = self.reactor.register_timer( + # self._unextrude, self.reactor.NEVER + # ) + self.printer.register_event_handler("klippy:ready", self.handle_ready) + self.printer.register_event_handler("klippy:connect", self.handle_connect) + + # Register motions sensors + # sensor_check = SensorChecker(printer) + # sensor_check.register_sensor(sensor_type, name) + # sensor_check.set_check_interval(interval) + # sensor_check.register_callback(callback, trigger) + # self.sensor_notes.update({name: sensor_check}) + + def handle_connect(self) -> None: + # TODO : fetch here the last filament state saved on variables file + save_vars = self.printer.lookup_object("save_variables", None) + if not save_vars: + self.state = FilamentStates.UNKNOWN + # Normal toolhead filament states will be FT == FT0, FT1, ..., FTX + sv_dict = save_vars.get_status(self.reactor.monotonic()) + variables: dict[str, typing.Any] = sv_dict.get("variables", None) + if not variables: + self.state = FilamentStates.UNKNOWN + + # TODO : Make this fetch last staste logic here + # variables.get() + # index_c = 0 + # dict.get() + # + # for key, item in variables.items(): + # if key == + # + gcode = self.printer.lookup_object("gcode") + self.bucket = self.printer.lookup_object("bucket", None) + if self.cutter_name: + if self.printer.lookup_object(f"cutter_sensor {self.cutter_name}", None): + self.cutter = self.printer.lookup_object( + f"cutter_sensor {self.cutter_name}" + ) + else: + raise self.printer.config_error( + f"{self.cutter_name} is undefined, expected cutter_sensor object" + ) + elif self.debug > 0: + gcode.respond_info( + f"Not using cutter_sensor for filament manager on extruder: {self.emotion.name}" + ) + logging.info( + f"Not using cutter_sensor for filament manager on extruder: {self.emotion.name}" + ) + + if self.aux_extruder_name: + if self.printer.lookup_object( + f"extruder_stepper {self.aux_extruder_name}", None + ): + self.aux_extruder = self.printer.lookup_object( + f"extruder_stepper {self.aux_extruder_name}" + ) + else: + raise self.printer.config_error( + f"{self.aux_extruder_name} is undefined, expected extruder_stepper object" + ) + elif self.debug > 0: + gcode.respond_info(f"Not using auxiliar extruder on filament manager") + logging.info(f"Not using auxiliar extruder on filament manager") + + def handle_ready(self) -> None: + """Handle `klippy:ready` event""" + # XXX: Klipper recomends not raising errors here + pass + + def _initialize_motion( + self, + eventtime: float | None, + motion_type: MotionType, + clean: bool = True, + ) -> None: + """Initialize filament motion motion""" + # if motion_type.lower() not in Motion: + # raise self.printer.command_error("Motion type expected either load or unload") + # + gcode = self.printer.lookup_object("gcode") + gcode.respond_info(f"initializing filament motion: {motion_type}") + gcode_macro = self.printer.lookup_object("gcode_macro") + toolhead = self.printer.lookup_object("toolhead") + if clean: + gcode.run_script_from_command("CLEAN_NOZZLE") + + if self.bucket: + gcode.respond_info(f"Moving toolhead FT{self.identifier}") + self.bucket.move_to_bucket(split=False) + + def _deregister_sensor(self, name) -> None: + """Deletes a sensor checker object by name""" + sensor_checker: SensorChecker = self.sensor_notes.pop(name) + del sensor_checker + + # def _unextrude(self, eventtime) -> float: + # if self.timeout: + # if self.extrude_count >= self.timeout: + # self.sensor_notes.get( + # self. + # ).toggle_check() # TEST: Stop the sensor checking + # completion = self.reactor.register_callback( + # self._handle_unload_finish() + # ) + # _ = completion.wait() + # return self.reactor.NEVER + # self.extrude_count += 1 + # self.emotion.move(distance=-1.0, speed=self.speed) + # return float(eventtime + float(1.0 / self.speed)) + # + # def _handle_unload_sensor_trigger(self, eventtime) -> None: + # if not self.sensors_check.get("unload-helper").active(): + # return + # if self.state == FilamentStates.UNLOADING: + # self.sensors_check.get("unload-helper").trigger_check() + + def unload(self) -> None: + gcode = self.printer.lookup_object("gcode") + if self.state != FilamentStates.LOADED: + raise gcode.error( + f"Cannot unload \n Extruder {self.name} is currently loading or unloaded." + ) + self.state = FilamentStates.UNLOADING + self.extrude_count = 0 + if self.cutter: + completion = self.reactor.register_callback(self.cutter.cut()) + completion.wait() + # self.unextrude_timer.update(self.reactor.NOW) + # self.sensors_check.get("unload-helper").trigger_check() + # + + def _handle_unload_finish(self, eventtime=None) -> None: + gcode = self.printer.lookup_object("gcode") + gcode.respond_info("Unload end method") + + # def _extrude(self, eventtime) -> None: + # if self.timeout: + # if self.extrude_count >= self.timeout: + # self.sensors_check.get(self.extrude_control_sensor_name).trigger_check() + # completion = self.reactor.register_callback(self.extrude_end) + # return completion.wait() + # self.extrude_count += 1 + # self.emotion.move(1, self.speed) + # return float(eventtime + float(1 / self.speed)) + # + # def _handle_load_sensor_trigger(self, eventtime) -> None: + # if not self.sensors_check.get("load-helper").active(): + # return + # if self.state == typing.Literal["loading"]: + # self.sensors_check.get("load-helper").trigger_check() + # + # cut + # register unextrude start when cut signals that is actually cut + # start helper sensor verification + # stop unextrude on timeout or when sensor is triggered + # clean or end with error + + # def load(self) -> None: + # gcode = self.printer.lookup_object("gcode") + # if self.state != FilamentStates.UNLOADED: + # raise gcode.error( + # f"Cannot load \n Extruder {self.name} is currently loaded or unloading" + # ) + # self.state = FilamentStates.LOADING + # self.extrude_count = 0 + # # start sensor verification + # # start extrude + # # stop extrude on cutter sensor or when timeout is reached + # # purge or end with error + # diff --git a/klippy/extras/toolhead_move_patterns.py b/klippy/extras/toolhead_move_patterns.py new file mode 100644 index 000000000000..4237be802681 --- /dev/null +++ b/klippy/extras/toolhead_move_patterns.py @@ -0,0 +1,67 @@ +import logging +import math + + +class ToolheadMovePatterns: + """Nozzle cleaning implementation""" + + def __init__(self, config) -> None: + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object("gcode") + self.toolhead = None + self.printer.register_event_handler("klippy:ready", self.handle_ready) + + self.gcode.register_command( + "CIRCUMFERENCE", self.cmd_CIRCUMFERENCE, self.cmd_CIRCUMFERENCE_helper + ) + + self.gcode.register_command( + "ELLIPSE", self.cmd_ELLIPSE, self.cmd_ELLIPSE_helper + ) + + def handle_ready(self) -> None: + """Handle klippy ready event""" + self.toolhead = self.printer.lookup_object("toolhead") + + def move_toolhead(self, x, y, speed) -> None: + self.toolhead.manual_move([x, y], speed) + self.toolhead.wait_moves() + + cmd_CIRCUMFERENCE_helper = "Moves the toolhead in a circle" + + def cmd_CIRCUMFERENCE(self, gcmd) -> None: + center_x = gcmd.get("CENTER_X", 0, parser=float) + center_y = gcmd.get("CENTER_Y", 0, parser=float) + radius = gcmd.get("RADIUS", 0, parser=float, minval=0) + vel = gcmd.get("VELOCITY", 50, parser=int, minval=50) + iterations = gcmd.get("ITERATIONS", 5, parser=int, minval=1) + for _ in range(0, iterations): + for angle in range(0, 360, 20): + x_position = center_x + radius * math.cos(math.radians(angle)) + y_position = center_y + radius * math.sin(math.radians(angle)) + self.move_toolhead(x_position, y_position, vel) + + cmd_ELLIPSE_helper = "Moves the toolhead in a ellipse" + + def cmd_ELLIPSE(self, gcmd) -> None: + """Move the toolhead in a ellipse + + h = center x + k = center y + """ + center_h = gcmd.get("H", 0, parser=float) + center_k = gcmd.get("K", 0, parser=float) + x_width = gcmd.get("X_WIDTH", 0, parser=float) + y_width = gcmd.get("Y_WIDTH", 0, parser=float) + vel = gcmd.get("VELOCITY", 50, parser=int) + iterations = gcmd.get("ITERATIONS", 5, parser=int, minval=1) + for _ in range(0, iterations): + for angle in range(0, 360, 10): + x_position = center_h + x_width * math.cos(math.radians(angle)) + y_position = center_k + y_width * math.sin(math.radians(angle)) + self.move_toolhead(x_position, y_position, vel) + + +def load_config(config): + return ToolheadMovePatterns(config) From 683cb0a9576061d0de25bc218398d2d0e787b448 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Thu, 26 Mar 2026 15:49:17 +0000 Subject: [PATCH 03/19] Integrate Happy-Hare extras --- .../extras/Happy-Hare/.github/CONTRIBUTING.md | 27 - .../.github/ISSUE_TEMPLATE/bug_report.yml | 79 - .../.github/ISSUE_TEMPLATE/config.yml | 6 - .../ISSUE_TEMPLATE/feature_request.yml | 77 - .../.github/workflows/needs-info-stale.yml | 25 - .../.github/workflows/needs-info.yml | 61 - .../Happy-Hare/.github/workflows/stale.yml | 33 - klippy/extras/Happy-Hare/.gitignore | 2 - klippy/extras/Happy-Hare/LICENSE | 674 ---- klippy/extras/Happy-Hare/README.md | 169 - .../Happy-Hare/components/mmu_server.py | 1282 -------- .../extras/Happy-Hare/config/addons/README.md | 7 - .../Happy-Hare/config/addons/blobifier.cfg | 1053 ------ .../Happy-Hare/config/addons/blobifier_hw.cfg | 28 - .../config/addons/mmu_eject_buttons.cfg | 35 - .../config/addons/mmu_eject_buttons_hw.cfg | 21 - .../config/addons/mmu_erec_cutter.cfg | 91 - .../config/addons/mmu_erec_cutter_hw.cfg | 10 - klippy/extras/Happy-Hare/config/base/mmu.cfg | 123 - .../extras/Happy-Hare/config/base/mmu.cfg.kms | 33 - .../extras/Happy-Hare/config/base/mmu.cfg.vvd | 35 - .../Happy-Hare/config/base/mmu_cut_tip.cfg | 296 -- .../Happy-Hare/config/base/mmu_form_tip.cfg | 178 - .../Happy-Hare/config/base/mmu_hardware.cfg | 485 --- .../config/base/mmu_hardware.cfg.kms | 477 --- .../config/base/mmu_hardware.cfg.vvd | 401 --- .../config/base/mmu_heater_vent.cfg | 56 - .../Happy-Hare/config/base/mmu_leds.cfg | 89 - .../Happy-Hare/config/base/mmu_macro_vars.cfg | 485 --- .../Happy-Hare/config/base/mmu_parameters.cfg | 820 ----- .../config/base/mmu_parameters.cfg.rs | 795 ----- .../config/base/mmu_parameters.cfg.ss | 783 ----- .../config/base/mmu_parameters.cfg.vs | 756 ----- .../Happy-Hare/config/base/mmu_purge.cfg | 95 - .../Happy-Hare/config/base/mmu_sequence.cfg | 665 ---- .../Happy-Hare/config/base/mmu_software.cfg | 567 ---- .../Happy-Hare/config/base/mmu_state.cfg | 124 - klippy/extras/Happy-Hare/config/mmu_vars.cfg | 8 - .../config/optional/client_macros.cfg | 132 - .../Happy-Hare/config/optional/mmu_menu.cfg | 146 - klippy/extras/Happy-Hare/extras/.pylintrc | 12 - klippy/extras/Happy-Hare/install.sh | 2919 ----------------- .../Happy-Hare/installer-dev/.gitignore | 1 - .../Happy-Hare/installer-dev/Dockerfile | 7 - .../extras/Happy-Hare/installer-dev/README.md | 27 - .../installer-dev/docker-compose.yaml | 19 - .../Happy-Hare/installer-dev/entrypoint.sh | 12 - klippy/extras/Happy-Hare/moonraker_update.txt | 11 - klippy/extras/Happy-Hare/pin_defs | 870 ----- klippy/extras/Happy-Hare/test/__init__.py | 0 .../Happy-Hare/test/components/__init__.py | 0 .../test/components/test_mmu_server.py | 88 - klippy/extras/Happy-Hare/test/runner.sh | 12 - .../test/support/no_toolchange.orig.gcode | 21 - .../test/support/toolchange.orig.gcode | 33 - .../Happy-Hare/utils/plot_sync_feedback.sh | 24 - .../Happy-Hare/utils/sim_sync_feedback.sh | 33 - .../Happy-Hare/utils/sync_feedback_sim.py | 2027 ------------ .../{Happy-Hare/extras => }/mmu/__init__.py | 0 .../extras/{Happy-Hare/extras => }/mmu/mmu.py | 0 .../mmu/mmu_calibration_manager.py | 0 .../mmu/mmu_environment_manager.py | 0 .../extras => }/mmu/mmu_extruder_monitor.py | 0 .../extras => }/mmu/mmu_led_manager.py | 0 .../{Happy-Hare/extras => }/mmu/mmu_logger.py | 0 .../extras => }/mmu/mmu_selector.py | 0 .../extras => }/mmu/mmu_sensor_manager.py | 0 .../{Happy-Hare/extras => }/mmu/mmu_shared.py | 0 .../extras => }/mmu/mmu_sync_controller.py | 0 .../extras => }/mmu/mmu_sync_controller.py3 | 0 .../mmu/mmu_sync_feedback_manager.py | 0 .../{Happy-Hare/extras => }/mmu/mmu_test.py | 0 .../{Happy-Hare/extras => }/mmu/mmu_utils.py | 0 .../{Happy-Hare/extras => }/mmu_encoder.py | 0 .../{Happy-Hare/extras => }/mmu_espooler.py | 0 .../{Happy-Hare/extras => }/mmu_led_effect.py | 0 .../{Happy-Hare/extras => }/mmu_leds.py | 0 .../{Happy-Hare/extras => }/mmu_machine.py | 0 .../{Happy-Hare/extras => }/mmu_sensors.py | 0 .../{Happy-Hare/extras => }/mmu_servo.py | 0 80 files changed, 17345 deletions(-) delete mode 100644 klippy/extras/Happy-Hare/.github/CONTRIBUTING.md delete mode 100644 klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/config.yml delete mode 100644 klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 klippy/extras/Happy-Hare/.github/workflows/needs-info-stale.yml delete mode 100644 klippy/extras/Happy-Hare/.github/workflows/needs-info.yml delete mode 100644 klippy/extras/Happy-Hare/.github/workflows/stale.yml delete mode 100644 klippy/extras/Happy-Hare/.gitignore delete mode 100644 klippy/extras/Happy-Hare/LICENSE delete mode 100644 klippy/extras/Happy-Hare/README.md delete mode 100644 klippy/extras/Happy-Hare/components/mmu_server.py delete mode 100644 klippy/extras/Happy-Hare/config/addons/README.md delete mode 100644 klippy/extras/Happy-Hare/config/addons/blobifier.cfg delete mode 100644 klippy/extras/Happy-Hare/config/addons/blobifier_hw.cfg delete mode 100644 klippy/extras/Happy-Hare/config/addons/mmu_eject_buttons.cfg delete mode 100644 klippy/extras/Happy-Hare/config/addons/mmu_eject_buttons_hw.cfg delete mode 100644 klippy/extras/Happy-Hare/config/addons/mmu_erec_cutter.cfg delete mode 100644 klippy/extras/Happy-Hare/config/addons/mmu_erec_cutter_hw.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu.cfg.kms delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu.cfg.vvd delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_cut_tip.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_form_tip.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg.kms delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg.vvd delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_heater_vent.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_leds.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_macro_vars.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.rs delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.ss delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.vs delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_purge.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_sequence.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_software.cfg delete mode 100644 klippy/extras/Happy-Hare/config/base/mmu_state.cfg delete mode 100644 klippy/extras/Happy-Hare/config/mmu_vars.cfg delete mode 100644 klippy/extras/Happy-Hare/config/optional/client_macros.cfg delete mode 100644 klippy/extras/Happy-Hare/config/optional/mmu_menu.cfg delete mode 100644 klippy/extras/Happy-Hare/extras/.pylintrc delete mode 100755 klippy/extras/Happy-Hare/install.sh delete mode 100644 klippy/extras/Happy-Hare/installer-dev/.gitignore delete mode 100644 klippy/extras/Happy-Hare/installer-dev/Dockerfile delete mode 100644 klippy/extras/Happy-Hare/installer-dev/README.md delete mode 100644 klippy/extras/Happy-Hare/installer-dev/docker-compose.yaml delete mode 100755 klippy/extras/Happy-Hare/installer-dev/entrypoint.sh delete mode 100644 klippy/extras/Happy-Hare/moonraker_update.txt delete mode 100644 klippy/extras/Happy-Hare/pin_defs delete mode 100644 klippy/extras/Happy-Hare/test/__init__.py delete mode 100644 klippy/extras/Happy-Hare/test/components/__init__.py delete mode 100644 klippy/extras/Happy-Hare/test/components/test_mmu_server.py delete mode 100755 klippy/extras/Happy-Hare/test/runner.sh delete mode 100644 klippy/extras/Happy-Hare/test/support/no_toolchange.orig.gcode delete mode 100644 klippy/extras/Happy-Hare/test/support/toolchange.orig.gcode delete mode 100755 klippy/extras/Happy-Hare/utils/plot_sync_feedback.sh delete mode 100755 klippy/extras/Happy-Hare/utils/sim_sync_feedback.sh delete mode 100644 klippy/extras/Happy-Hare/utils/sync_feedback_sim.py rename klippy/extras/{Happy-Hare/extras => }/mmu/__init__.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_calibration_manager.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_environment_manager.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_extruder_monitor.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_led_manager.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_logger.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_selector.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_sensor_manager.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_shared.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_sync_controller.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_sync_controller.py3 (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_sync_feedback_manager.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_test.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu/mmu_utils.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu_encoder.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu_espooler.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu_led_effect.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu_leds.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu_machine.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu_sensors.py (100%) rename klippy/extras/{Happy-Hare/extras => }/mmu_servo.py (100%) diff --git a/klippy/extras/Happy-Hare/.github/CONTRIBUTING.md b/klippy/extras/Happy-Hare/.github/CONTRIBUTING.md deleted file mode 100644 index 5c5fbf695568..000000000000 --- a/klippy/extras/Happy-Hare/.github/CONTRIBUTING.md +++ /dev/null @@ -1,27 +0,0 @@ -## How to contribute to Happy Hare - -#### **Do you need help with your setup?** - -* Please ask your questions in the [Discord server](https://discord.gg/HXEHUb9W) instead. Github issues is meant for bugs and feature requests, not your specific setup problems. - -#### **Did you find a bug?** - -* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/moggieuk/Happy-Hare/issues). - -* If you're unable to find an issue addressing the problem, [open a new one](https://github.com/rails/rails/issues/new). Be sure to include a **title and clear description**, as much **relevant information** as possible and **log files**. - -#### **Did you write a patch that fixes a bug?** - -* Open a new GitHub pull request with the patch. - -* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. - -#### **Do you intend to add a new feature or change an existing one?** - -* Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. - -* Changes that break existing setups will probably be rejected. - -Thanks! :heart: :heart: :heart: - -- Moggieuk diff --git a/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/bug_report.yml b/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 2e3c4d97fcda..000000000000 --- a/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Bug report -description: Report a bug with enough detail for us to reproduce it -title: "[Bug]: " -labels: ["bug", "needs-triage"] -body: - - type: markdown - attributes: - value: "Thanks! Please complete all required fields." - - - type: input - id: summary - attributes: - label: Summary - placeholder: "One-sentence description" - validations: - required: true - - - type: textarea - id: steps - attributes: - label: Steps to reproduce - description: "Numbered steps are best" - placeholder: "1) ... 2) ... 3) ..." - validations: - required: false - - - type: textarea - id: expected - attributes: - label: Expected behavior - validations: - required: false - - - type: textarea - id: actual - attributes: - label: Actual behavior - validations: - required: false - - - type: dropdown - id: mmu_type - attributes: - label: MMU Type - options: ["3D Chameleon", "3MS", "AngryBeaver", "BoxTurtle", "EMU", "ERCF", "KMS", "MMX", "QuattroBox", "NightOwl", "Pico", "Prusa", "Tradrack", "ViViD", "Other/Unknown"] - validations: - required: true - - - type: input - id: hh_version - attributes: - label: Happy Hare version in '3.1.4-16' format - validations: - required: true - - - type: input - id: klipper_version - attributes: - label: Klipper version in '0.13.0-121' format - validations: - required: true - - - type: textarea - id: logs - attributes: - label: Relevant logs (`mmu.log`) - description: "The `mmu.log` is almost always needed. Optional if a klipper error, please attach `klippy.log`" - render: shell - - - type: checkboxes - id: checklist - attributes: - label: Checklist - options: - - label: I searched existing issues - required: true - - label: I can reproduce with the latest version of Klipper and Happy Hare - required: false - diff --git a/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/config.yml b/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 838720f0970b..000000000000 --- a/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,6 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Ask a usage question (join Happy Hare Discord) - url: https://discord.gg/98TYYUf6f2 - about: Q&A and troubleshooting belong in Discussions - diff --git a/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/feature_request.yml b/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index a8cb4bb9aa6e..000000000000 --- a/klippy/extras/Happy-Hare/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Feature request -description: Suggest an idea or improvement -title: "[Feature]: " -labels: ["enhancement", "needs-triage"] -assignees: [] -body: - - type: markdown - attributes: - value: | - Thanks for contributing! Please complete all required fields. - - - type: input - id: summary - attributes: - label: Summary - description: One-sentence description of the request - placeholder: "Add a concise summary" - validations: - required: true - - - type: textarea - id: problem - attributes: - label: Problem statement - description: What problem are you trying to solve? Who is impacted and how often? - placeholder: "When __, I want __ so that __." - validations: - required: true - - - type: textarea - id: proposal - attributes: - label: Proposed solution / approach - description: Describe the idea. Include UX flow, API shape, or UI sketches if relevant. - placeholder: "Explain the solution and why it helps." - validations: - required: true - - - type: dropdown - id: priority - attributes: - label: Priority / Impact - options: - - P0 – Critical - - P1 – High - - P2 – Medium - - P3 – Low - validations: - required: true - - - type: dropdown - id: area - attributes: - label: Area(s) of the product - description: Pick one or more - multiple: true - options: - - HappyHare (Klipper or Moonraker) - - Mainsail UI - - Fluidd UI - - KlipperScreen UI - - Documentation - - Other / Don't Know - - - type: checkboxes - id: checklist - attributes: - label: Checklist - options: - - label: Have you running the lastest Happy Hare release - required: true - - label: I searched existing issues - required: true - - label: I have asked on the Happy Hare Discord - required: false - - label: I’m willing to contribute a PR - required: false diff --git a/klippy/extras/Happy-Hare/.github/workflows/needs-info-stale.yml b/klippy/extras/Happy-Hare/.github/workflows/needs-info-stale.yml deleted file mode 100644 index d97a72259074..000000000000 --- a/klippy/extras/Happy-Hare/.github/workflows/needs-info-stale.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Close unresolved needs-info issues -on: - workflow_dispatch: # manual for now -# schedule: -# - cron: "30 1 * * *" # daily UTC -permissions: - issues: write -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v10 - with: - # Only look at issues that are marked as needs-info - only-issue-labels: "needs-info" - # Treat "needs-info" as the stale label (no extra label) - stale-issue-label: "needs-info" - # Comment right away, then close in 7 days if still inactive - days-before-stale: 0 - days-before-issue-close: 7 - stale-issue-message: > - This issue needs more info. Please update the top comment with the requested details. - close-issue-message: > - Closing due to missing information. Please reopen after updating the details. - diff --git a/klippy/extras/Happy-Hare/.github/workflows/needs-info.yml b/klippy/extras/Happy-Hare/.github/workflows/needs-info.yml deleted file mode 100644 index 0f13c4c15bbe..000000000000 --- a/klippy/extras/Happy-Hare/.github/workflows/needs-info.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Needs info check -on: - workflow_dispatch: # manual for now -# issues: -# types: [opened, edited] - -jobs: - gate: - if: ${{ github.event.issue.user.type != 'Bot' }} - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - uses: actions/github-script@v7 - with: - script: | - const issue = context.payload.issue; - const body = (issue.body || ""); - const lc = body.toLowerCase(); - - // Simple heuristics: length + must-have sections/words - const required = ["summary", "steps to reproduce", "expected", "actual", "mmu-type", "hh-version", "klipper-version"]; - const missing = required.filter(s => !lc.includes(s)); - - const insufficient = body.length < 100 || missing.length > 0; - - // Helpers - const hasLabel = (name) => - (issue.labels || []).some(l => (l.name || l) === name); - - if (insufficient && !hasLabel("needs-info")) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, repo: context.repo.repo, - issue_number: issue.number, labels: ["needs-info"] - }); - await github.rest.issues.createComment({ - owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, - body: -`Thanks! We need a bit more detail${missing.length ? ` (missing: ${missing.join(", ")})` : ""}. - -Please edit the top comment to include: -- Concise summary -- Exact steps to reproduce -- Expected vs. Actual -- MMU Type (important) -- Happy-Hare version -- Klipper version - -Once updated, this label will be removed automatically.` - }); - } else if (!insufficient && hasLabel("needs-info")) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, repo: context.repo.repo, - issue_number: issue.number, name: "needs-info" - }); - await github.rest.issues.createComment({ - owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, - body: "Thanks for the details! Removed `needs-info`." - }); - } - diff --git a/klippy/extras/Happy-Hare/.github/workflows/stale.yml b/klippy/extras/Happy-Hare/.github/workflows/stale.yml deleted file mode 100644 index 33f32e6dc7ab..000000000000 --- a/klippy/extras/Happy-Hare/.github/workflows/stale.yml +++ /dev/null @@ -1,33 +0,0 @@ -# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. -# -# You can adjust the behavior by modifying this file. -# For more information, see: -# https://github.com/actions/stale -name: Mark stale issues and pull requests - -on: - schedule: - - cron: '0 */4 * * *' - -jobs: - stale: - - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - uses: actions/stale@v5 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-pr-stale: 180 - days-before-issue-stale: 30 - days-before-close: 14 - remove-stale-when-updated: true - any-of-labels: believe fixed / answered, more info needed, wontfix, incomplete - stale-issue-message: "This issue is stale because it has been open for over 30 days with no activity. It will be closed in 14 days automatically unless there is activity." - close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." - stale-issue-label: 'stale' - stale-pr-message: "This PR is stale because it has been open for 180 days with no activity. It will be closed in 14 days automatically unless there is activity." - close-pr-message: "This PR was closed because it has been inactive for 14 days since being marked as stale." diff --git a/klippy/extras/Happy-Hare/.gitignore b/klippy/extras/Happy-Hare/.gitignore deleted file mode 100644 index 23336f766544..000000000000 --- a/klippy/extras/Happy-Hare/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -my_*.cfg -.DS_Store diff --git a/klippy/extras/Happy-Hare/LICENSE b/klippy/extras/Happy-Hare/LICENSE deleted file mode 100644 index f288702d2fa1..000000000000 --- a/klippy/extras/Happy-Hare/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/klippy/extras/Happy-Hare/README.md b/klippy/extras/Happy-Hare/README.md deleted file mode 100644 index 3106dcbdb7f6..000000000000 --- a/klippy/extras/Happy-Hare/README.md +++ /dev/null @@ -1,169 +0,0 @@ -

- Happy Hare -

Happy Hare

-

- -

-Universal Automated Filament Changer / MMU driver for Klipper -

- -

- - -   - -   - -   - -   - -
- -   - -

- -Happy Hare is the original open-source filament changer controller for multi-color printing. Its philosophy is to provide a universal control system that adapts to your choice of MMU (Multi-Material Unit). If you switch MMUs, the software transitions seamlessly with you. Currently, it fully supports **ERCF**, **Tradrack**, **Box Turtle**, **Angry Beaver**, **Night Owl**, **3MS**, **3D Chameleon**, **QuattroBox**, **PicoMMU**, **KMS**, **BTT ViViD**, **EMU** and various other custom designs. - -The system is implemented as a Klipper extension (primarily using Python modules) to control MMUs and AFCs. It also provides functionality that can be customized through Klipper macros. With extensive configuration options for personalization, it includes an installer to simplify the initial setup for popular MMU and MCU types. For details about the different conceptual types of MMUs and the functions of their various sensors, refer to the [conceptual MMU guide](https://github.com/moggieuk/Happy-Hare/wiki/Conceptual-MMU). This guide is particularly useful for customized setups. For the best experience, pair it with the [KlipperScreen for Happy Hare](https://github.com/moggieuk/KlipperScreen-Happy-Hare-Edition) project together with a fully integrated Mainsail and Fluidd user experience. Extensive documentation is available in the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki). - - - -Happy Hare is under active development, with a meticulous focus on the quality of multi-color printing. It has benefited from insights gained over two years from thousands of users. While the experience is highly polished, development continues in three key areas: - -- **Additional MMU Support:** _Striving for inclusivity—support for Prusa MMU and others is in progress_ -- **v4 Rework:** _This release will allow for even more modularity and in particular support for dissimilar MMU/AFC's on the same printer! Yes, mix your old ERCF and BoxTurtle on the same machine or even direct to different toolheads in a IDEX design!_ - -Some users have inquired about making donations to support this project (and to keep my coffee or G&T supply steady!). While this project is a labor of love and not financially motivated, it is a substantial undertaking—comprising 18,000 lines of Python code, 10,000 lines of documentation, 160 illustrations and 6,000 lines of macros/configuration. If you’ve found value in Happy Hare and wish to contribute, donations can be made via PayPal https://www.paypal.me/moggieuk. Any support will be spent improving your experience with your favorite MMU/AFC. Thank you! -

- -
- -**Don't forget to join the dedicated Happy Hare community forum here: https://discord.gg/aABQUjkZPk** - -
- -## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) Just a few of the features: - -- Support almost any brand of MMU (including mods) or custom monsters: - - ERCF - - Tradrack - - Box Turtle - - Angry Beaver - - Night Owl - - 3MS - - 3D Chameleon - - Quattro Box - - PicoMMU - - MMX - - KSM - - BTT ViViD - - Custom... -- Klipperscreen and Mainsail/Fluidd UI -- Support for all type of sensor: pre-gate, post-gear, combiner gate sensors, extruder entry sensors, toolhead sensors -- Full Spoolman integration -- Multiple MMUs managed as one (limited to type-A until v4 release) -- Support for motorized eSpooler filament buffer systems for rewinding -- Suite of startup macros that include sophisticated parking options for filament change or error operations -- Implements a Tool-to-Gate mapping so that the physical spool can be mapped to any tool -- EndlessSpool allowing a spool to automatically be mapped and take over from a spool that runs out -- Sophisticated logging options (console and separate mmu.log file) -- Can define material type and color in each gate for visualization and customized settings (like Pressure Advance) -- Automated calibration and tuning for easy setup -- Supports MMU "bypass" gate functionality -- Moonraker update-manager support -- Moonraker gcode pre-parsing to extract important print information -- Complete persistence of state and statistics across restarts -- Optional integrated encoder driver that validates filament movement, runout, clog detection and flow rate verification! -- Vast customization options most of which can be changed and tested at runtime -- Integrated help, testing and soak-testing procedures -- Gcode pre-processor check that all the required tools are available! -- Drives LEDs for functional feed and some bling! -- Built in tip forming and filament cutter support (both toolhead and at MMU) -- Synchronized movement of extruder and gear motors (with sync feedback control) to overcome friction and even work with FLEX materials! -- Lots more... Detail change log can be found in the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Change-Log) - -Controlling my oldest ERCF MMU with companion [customized KlipperScreen](https://github.com/moggieuk/Happy-Hare/wiki/Basic-Operation#---klipperscreen-happy-hare) for easy touchscreen MMU control and new Mainsail/Fluidd integration! - -

universal_mmu_driver.png

- -

KlipperScreen-Happy Hare editionMailsail/Fluidd support

- -
- -## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) Installation - -Ok, ready to get started? The module can be installed into an existing Klipper setup with the supplied install script. Once installed it will be added to Moonraker update-manager to easy updates like other Klipper plugins. Full installation documentation is in the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Home) but start with cloning the repo onto your rpi: - -``` -cd ~ -git clone https://github.com/moggieuk/Happy-Hare.git -``` - -
- -## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) Documentation - - - - - -
wiki -MMU's are complexd! Fortunately Happy Hare has elaborate documentation logically organized in the Wiki - -


- -**Other Resources:** -

- -Great (english) overview including Mainsail UI support - -
-Instructional video (german) created by Crydteam - -
-Happy Hare introduction (introduction) by Silverback -
- -
- -
- -## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) Just how good a MMU multi-color prints? - -Although the journey to calibrating and setup can be a frustrating one, I wanted to share @igiannakas (ERCFv2 + Orca Slicer + Happy Hare) example prints here. Click on the image to zoom it. Incredible! :cool: :clap: - -

Example Prints

-

Example Prints

-
- -## ![#f03c15](https://github.com/moggieuk/Happy-Hare/wiki/resources/f03c15.png) ![#c5f015](https://github.com/moggieuk/Happy-Hare/wiki/resources/c5f015.png) ![#1589F0](https://github.com/moggieuk/Happy-Hare/wiki/resources/1589F0.png) My Testing and Setup: - -Most of the development of Happy Hare was done on my trusty old ERCF v1.1 setup but as it's grown, so has my collection of MMU's and MCU controllers. Multi-color printing is addictive but can be frustrating during setup and learning. Be patient and use the forums for help! **But first read the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Home)!** - -

My Setup

-

-There once was a printer so keen,
-To print in red, yellow, and green.
-It whirred and it spun,
-Mixing colors for fun,
-The most vibrant prints ever seen!
-

- ---- - -```yml - (\_/) - ( *,*) - (")_(") Happy Hare Ready -``` diff --git a/klippy/extras/Happy-Hare/components/mmu_server.py b/klippy/extras/Happy-Hare/components/mmu_server.py deleted file mode 100644 index a5c8912db6ab..000000000000 --- a/klippy/extras/Happy-Hare/components/mmu_server.py +++ /dev/null @@ -1,1282 +0,0 @@ -# Happy Hare MMU Software -# Moonraker support for a file-preprocessor that injects MMU metadata into gcode files -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# -# Original slicer parsing -# Copyright (C) 2023 Kieran Eglin <@kierantheman (discord)>, -# -# (\_/) -# ( *,*) -# (")_(") MMU Ready -# -# This file may be distributed under the terms of the GNU GPLv3 license. -# -from __future__ import annotations -import json -import logging, os, sys, re, time, asyncio -import runpy, argparse, shutil, traceback, tempfile, filecmp -from typing import ( - TYPE_CHECKING, - List, - Dict, - Any, - Optional, - Union, - cast -) - -if TYPE_CHECKING: - from .spoolman import SpoolManager, DB_NAMESPACE, ACTIVE_SPOOL_KEY - from ..common import WebRequest - from ..common import RequestType - from ..confighelper import ConfigHelper - from .http_client import HttpClient, HttpResponse - from .database import MoonrakerDatabase - from .announcements import Announcements - from .klippy_apis import KlippyAPI as APIComp - from .history import History - from tornado.websocket import WebSocketClientConnection - -MMU_NAME_FIELD = 'printer_name' -MMU_GATE_FIELD = 'mmu_gate_map' -MIN_SM_VER = (0, 18, 1) - -DB_NAMESPACE = "moonraker" -ACTIVE_SPOOL_KEY = "spoolman.spool_id" - -class MmuServer: - def __init__(self, config: ConfigHelper): - self.config = config - self.server = config.get_server() - self.printer_info = self.server.get_host_info() - self.spoolman = None - if config.has_section("spoolman"): # Avoid exception if spoolman not configured - self.spoolman: SpoolManager = self.server.load_component(config, "spoolman", None) - self.spoolman: SpoolManager = self.server.lookup_component("spoolman", None) - self.klippy_apis: APIComp = self.server.lookup_component("klippy_apis") - self.http_client: HttpClient = self.server.lookup_component("http_client") - self.database: MoonrakerDatabase = self.server.lookup_component("database") - - # Full cache of spool_ids and location + key attributes (printer, gate, attr_dict)) - # Example: {2: ('BigRed', 0, {"material": "pla", "color": "ff56e0"}), 3: ('BigRed', 3, {"material": "abs"}), ... - self.spool_location = {} - - self.nb_gates = None # Set during initialization to the size of the MMU or 1 if standalone - self.cache_lock = asyncio.Lock() # Lock to serialize a async calls for Happy Hare - - # Spoolman filament info retrieval functionality and update reporting - if self.spoolman: - self.server.register_remote_method("spoolman_refresh", self.refresh_cache) - self.server.register_remote_method("spoolman_get_filaments", self.get_filaments) # "get" mode - self.server.register_remote_method("spoolman_push_gate_map", self.push_gate_map) # "push" mode - self.server.register_remote_method("spoolman_pull_gate_map", self.pull_gate_map) # "pull" mode - self.server.register_remote_method("spoolman_clear_spools_for_printer", self.clear_spools_for_printer) - self.server.register_remote_method("spoolman_set_spool_gate", self.set_spool_gate) - self.server.register_remote_method("spoolman_unset_spool_gate", self.unset_spool_gate) - self.server.register_remote_method("spoolman_get_spool_info", self.display_spool_info) - self.server.register_remote_method("spoolman_display_spool_location", self.display_spool_location) - - # Moonraker lane data push for slicer integration - self.server.register_remote_method("moonraker_push_lane_data", self.push_lane_data) - self.server.register_remote_method("moonraker_cleanup_lane_data", self.cleanup_lane_data) - - # Replace file_manager/metadata with this file - self.setup_placeholder_processor(config) - - # Options - self.update_location = self.config.getboolean("update_spoolman_location", True) - - async def _get_spoolman_version(self) -> tuple[int, int, int] | None: - response = await self.http_client.get(url=f'{self.spoolman.spoolman_url}/v1/info') - if response.status_code == 404: - logging.info(f"'{self.spoolman.spoolman_url}/v1/info' not found") - return False - elif response.has_error(): - err_msg = self.spoolman._get_response_error(response) - logging.error(f"Attempt to get info from spoolman failed: {err_msg}") - return False - else: - logging.info("info field in spoolman retrieved") - return tuple([int(n) for n in response.json()['version'].split('.')]) - - async def component_init(self) -> None: - if self.spoolman is None: - logging.warning("Spoolman not available. Happy Hare remote methods not available") - return - - # Get current printer hostname - self.printer_hostname = self.printer_info["hostname"] - self.spoolman_has_extras = False - asyncio.create_task(self._init_spoolman(retry=3)) # Spoolman may start up after us so retry a few times - - async def _init_spoolman(self, retry=1) -> bool: - ''' - Return True if connected, False if not. Set's self.spoolman_has_extras is - ''' - async with self.cache_lock: - for _ in range(retry): - self.spoolman_version = await self._get_spoolman_version() - if self.spoolman_version: - logging.info("Contacted Spoolman") - break - logging.warning(f"Spoolman not available. {'Retrying in 2 seconds...' if retry > 1 else ''}") - await asyncio.sleep(2) - - extras = False - if self.spoolman_version and self.spoolman_version >= MIN_SM_VER: - # Make sure db has required extra fields - extras = True - fields = await self._get_extra_fields("spool") - if MMU_NAME_FIELD not in fields: - extras = extras and await self._add_extra_field("spool", field_name="Printer Name", field_key=MMU_NAME_FIELD, field_type="text", default_value="") - if MMU_GATE_FIELD not in fields: - extras = extras and await self._add_extra_field("spool", field_name="MMU Gate", field_key=MMU_GATE_FIELD, field_type="integer", default_value=-1) - - # Create cache of spool location from Spoolman db for effeciency - if extras: - await self._build_spool_location_cache(silent=True) - self.spoolman_has_extras = extras - - elif self.spoolman_version: - logging.error(f"Could not initialize Spoolman db for Happy Hare. Spoolman db version too old (found {self.spoolman_version} < {MIN_SM_VER})") - else: - logging.error("Could not connect to Spoolman db. Perhaps it is not initialized yet? Will try again on next request") - return False - return True - - async def _check_init_spoolman(self, silent=False) -> bool: - if not self.spoolman_has_extras: - db_awake = await self._init_spoolman() - if not silent: - if not db_awake: - await self._log_n_send("Couldn't connect to Spoolman. Maybe not configured/running yet (check moonraker.log).\nUse MMU_SPOOLMAN REFRESH=1 to force retry") - elif not self.spoolman_has_extras: - await self._log_n_send("Incompatible Spoolman version for this feature. Check moonraker.log") - return self.spoolman_has_extras - - # !TODO: implement mainsail/fluidd gui prompts? - async def _log_n_send(self, msg, error=False, prompt=False, silent=False): - ''' - logs and sends msg to the klipper console - ''' - if error: - logging.error(msg) - else: - logging.info(msg) - if not silent: - if self._mmu_backend_enabled(): - error_flag = "ERROR=1" if error else "" - msg = msg.replace("\n", "\\n") # Get through klipper filtering - await self.klippy_apis.run_gcode(f"MMU_LOG MSG='{msg}' {error_flag}") - else: - for msg in msg.split("\n"): - await self.klippy_apis.run_gcode(f"M118 {msg}") - if error : - await self.klippy_apis.pause_print() - - async def _init_mmu_backend(self): - ''' - Initialize MMU backend and check if enabled - - returns: - @return: True if initialized, False otherwise - ''' - self.mmu_backend_present = 'mmu' in await self.klippy_apis.get_object_list() - if self.mmu_backend_present: - self.mmu_backend_config = await self.klippy_apis.query_objects({"mmu": None}) - self.mmu_enabled = self.mmu_backend_config.get('mmu', {}).get('enabled', False) - else: - self.mmu_enabled = False - logging.info(f"MMU backend present: {self.mmu_backend_present}") - logging.info(f"MMU backend enabled: {self.mmu_enabled}") - return True - - def _mmu_backend_enabled(self): - if not hasattr(self, 'mmu_backend_present'): - return False - return self.mmu_backend_present and self.mmu_enabled - - async def _initialize_mmu(self): - ''' - Initialize mmu gate map if not already done - - returns: - @return: True once initialized - ''' - if not hasattr(self, 'mmu_backend_present'): - await self._init_mmu_backend() - if self._mmu_backend_enabled(): - if self.config.has_option("num_gates"): - logging.warning("The 'num_gates' option in the moonraker [mmu_server] section is ignored when an MMU backend is present and enabled.") - self.nb_gates = self.mmu_backend_config.get('mmu', {}).get('num_gates', 0) - else: - self.nb_gates = self.config.getint("num_gates", 1) # for standalone usage (no mmu backend considering standard or (custom defined) printer setup) - logging.info(f"MMU num_gates: {self.nb_gates}") - return True - - async def _get_extra_fields(self, entity_type) -> bool: - ''' - Helper to gets all extra fields for the entity type - ''' - response = await self.http_client.get(url=f'{self.spoolman.spoolman_url}/v1/field/{entity_type}') - if response.status_code == 404: - logging.info(f"'{self.spoolman.spoolman_url}/v1/field/{entity_type}' not found") - return False - elif response.has_error(): - err_msg = self.spoolman._get_response_error(response) - logging.error(f"Attempt to get extra fields failed: {err_msg}") - return False - else: - logging.info(f"Extra fields for {entity_type} found") - return [r['key'] for r in response.json()] - - async def _add_extra_field(self, entity_type, field_key, field_name, field_type, default_value) -> bool: - ''' - Helper to add a new field to the extra field of the Spoolman db - ''' - #default_value = json.dumps(default_value) if field_type == 'text' else default_value - response = await self.http_client.post( - url=f'{self.spoolman.spoolman_url}/v1/field/{entity_type}/{field_key}', - body={"name" : field_name, "field_type" : field_type, "default_value" : json.dumps(default_value)} - ) - if response.status_code == 404: - logging.info(f"'{self.spoolman.spoolman_url}/v1/field/spool/{field_key}' not found") - return False - elif response.has_error(): - err_msg = self.spoolman._get_response_error(response) - logging.error(f"Attempt add field {field_name} failed: {err_msg}") - return False - logging.info(f"Field {field_name} added to Spoolman db for entity type {entity_type}") - logging.info(" -fields: %s", response.json()) - return True - - async def _fetch_spool_info(self, spool_id) -> dict | None: - ''' - Retrieve an individual spool_info record - ''' - response = await self.spoolman.http_client.request( - method="GET", - url=f'{self.spoolman.spoolman_url}/v1/spool/{spool_id}', - body=None) - if response.status_code == 404: - logging.error(f"'{self.spoolman.spoolman_url}/v1/spool/{spool_id}' not found") - return None - elif response.has_error(): - err_msg = self.spoolman._get_response_error(response) - logging.info(f"Attempt to fetch spool info failed: {err_msg}") - return None - spool_info = response.json() - return spool_info - - def _get_filament_attr(self, spool_info) -> dict: - spool_id = spool_info["id"] - filament = spool_info["filament"] - name = filament.get('name', '') - material = filament.get('material', '') - color_hex = filament.get('color_hex', '').strip('#')[:8].lower() # Remove problematic First # character if present - temp = filament.get('settings_extruder_temp', '') - bed_temp = filament.get('settings_bed_temp', '') - vendor = filament.get('vendor', {}).get('name', '') - filament_id = filament.get('id', '') - return {'spool_id': spool_id, 'material': material, 'color': color_hex, 'name': name, 'temp': temp, 'bed_temp': bed_temp, 'vendor': vendor, 'filament_id': filament_id} - - async def _build_spool_location_cache(self, fix=False, silent=False) -> bool: - ''' - Helper to get all spools and gates assigned to printers from Spoolman db and cache them - ''' - logging.info("Building spool location cache from Spoolman db") - try: - self.spool_location.clear() - # Fetch all spools - errors = "" - assignments = {} - sids_to_fix = [] - reponse = await self.http_client.get(url=f'{self.spoolman.spoolman_url}/v1/spool') - for spool_info in reponse.json(): - spool_id = spool_info['id'] - printer_name = json.loads(spool_info['extra'].get(MMU_NAME_FIELD, "\"\"")).strip('"') - mmu_gate = int(spool_info['extra'].get(MMU_GATE_FIELD, -1)) - filament_attr = self._get_filament_attr(spool_info) - self.spool_location[spool_id] = (printer_name, mmu_gate, filament_attr) - - if printer_name and mmu_gate >= 0: - if printer_name not in assignments: - assignments[printer_name] = {} - if mmu_gate not in assignments[printer_name]: - assignments[printer_name][mmu_gate] = [] - assignments[printer_name][mmu_gate].append(spool_id) - - # Highlight errors - if printer_name and mmu_gate < 0: - errors += f"\n - Spool {spool_id} has printer {printer_name} but no mmu_gate assigned" - sids_to_fix.append(spool_id) - if mmu_gate >= 0 and not printer_name: - errors += f"\n - Spool {spool_id} has mmu_gate {mmu_gate} but no printer assigned" - sids_to_fix.append(spool_id) - - for p, gates in assignments.items(): - for g, spool_list in gates.items(): - if len(spool_list) > 1: - errors += f"\n - Printer {p} @ gate {g} has multiple spool ids: {spool_list}" - sids_to_fix.extend(spool_list[1:]) - except Exception as e: - await self._log_n_send(f"Failed to retrieve spools from spoolman: {str(e)}", error=True, silent=silent) - return False - - if errors: - if fix: - errors += "\nWill attempt to fix..." - await self._log_n_send(f"Warning - Inconsistencies found in Spoolman db:{errors}", silent=silent) - - if fix: - tasks = {sid: self._unset_spool_gate(sid, silent=silent) for sid in sids_to_fix} - results = await asyncio.gather(*tasks.values()) - - # Log results and update cache - for sid, result in zip(tasks.keys(), results): - if result: - old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) - self.spool_location[sid] = ('', -1, filament_attr) - await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate}", silent=silent) - return True - - # Function to find the first spool_id with a matching 'printer/gate', just 'gate' or just 'printer' - def _find_first_spool_id(self, target_printer, target_gate): - return next((spoolid - for spoolid, (printer, gate, _) in self.spool_location.items() - if (target_printer is None or printer == target_printer) and gate == target_gate - ), -1) - - # Function to find all the spool_ids with a matching 'printer/gate', just 'gate' or just 'printer' - def _find_all_spool_ids(self, target_printer, target_gate): - return [ - spoolid - for spoolid, (printer, gate, _) in self.spool_location.items() - if (target_printer is None or printer == target_printer) and (target_gate is None or gate == target_gate) - ] - - async def _set_spool_gate(self, spool_id, printer, gate, silent=False) -> bool: - if not await self._check_init_spoolman(): return - - # Use the PATCH method on the spoolman api - if not silent: - logging.info(f"Setting spool {spool_id} for printer {printer} @ gate {gate}") - data = {'extra': {MMU_NAME_FIELD: json.dumps(f"{printer}"), MMU_GATE_FIELD: json.dumps(gate)}} - if self.update_location: - data['location'] = f"{printer} @ MMU Gate:{gate}" - response = await self.http_client.request( - method="PATCH", - url=f"{self.spoolman.spoolman_url}/v1/spool/{spool_id}", - body=json.dumps(data) - ) - if response.status_code == 404: - logging.error(f"'{self.spoolman.spoolman_url}/v1/spool/{spool_id}' not found") - await self._log_n_send(f"SpoolId {spool_id} not found", error=True, silent=False) - return False - elif response.has_error(): - err_msg = self.spoolman._get_response_error(response) - logging.error(f"Attempt to set spool failed: {err_msg}") - await self._log_n_send(f"Failed to set spool {spool_id} for printer {printer}. Look at moonraker.log for more details.", error=True, silent=False) - return False - return True - - async def _unset_spool_gate(self, spool_id, silent=False) -> bool: - if not await self._check_init_spoolman(): return - - # Use the PATCH method on the spoolman api - if not silent: - logging.info(f"Unsetting gate map on spool id {spool_id}") - data = {'extra': {MMU_NAME_FIELD: json.dumps(""), MMU_GATE_FIELD: json.dumps(-1)}} - if self.update_location: - data['location'] = "" - response = await self.http_client.request( - method="PATCH", - url=f"{self.spoolman.spoolman_url}/v1/spool/{spool_id}", - body=json.dumps(data) - ) - if response.status_code == 404: - logging.error(f"'{self.spoolman.spoolman_url}/v1/spool/{spool_id}' not found") - await self._log_n_send(f"SpoolId {spool_id} not found", error=True, silent=False) - return False - elif response.has_error(): - err_msg = self.spoolman._get_response_error(response) - logging.error(f"Attempt to unset spool failed: {err_msg}") - await self._log_n_send(f"Failed to unset spool {spool_id}. Look at moonraker.log for more details", error=True, silent=False) - return False - return True - - async def _send_gate_map_update(self, gate_ids, replace=False, silent=False) -> bool: - ''' - Retrieve filament attributes for list of (gate, spool_id) tuples - Pass back to Happy Hare. - - If no mmu backend has been detected, ignore the request - ''' - if self._mmu_backend_enabled(): - gate_dict = { - gate: ( - {'spool_id': -1} if spool_id < 0 else - self.spool_location.get(spool_id)[2].copy() - if self.spool_location.get(spool_id) - else logging.error(f"Spool id {spool_id} requested but not found in spoolman") - ) - for gate, spool_id in gate_ids - } - try: - await self.klippy_apis.run_gcode(f"MMU_GATE_MAP MAP=\"{gate_dict}\" {'REPLACE=1' if replace else ''} FROM_SPOOLMAN=1 QUIET=1") - except Exception as e: - await self._log_n_send(f"Exception running MMU_GATE_MAP gcode: {str(e)}", error=True, silent=silent) - return False - return True - - async def refresh_cache(self, fix=False, silent=False) -> bool: - ''' - Rebuilds the local cache of essential spool information - ''' - if not await self._check_init_spoolman(): return - async with self.cache_lock: - await self._initialize_mmu() - return await self._build_spool_location_cache(fix=fix, silent=silent) - - async def get_filaments(self, gate_ids, silent=False) -> bool: - ''' - Retrieve filament attributes for list of (gate, spool_id) tuples - Pass back to Happy Hare. Does not require extended Spoolman db - ''' - async with self.cache_lock: - return await self._send_gate_map_update(gate_ids, silent=silent) - - async def push_gate_map(self, gate_ids=None, silent=False) -> bool: - ''' - Store the gate map for the printer for a list of (gate, spool_id) tuples. - This attempts to reduce the number of necessary tasks and then run them in parallel - Then updates Happy Hare with filament attributes - ''' - if not await self._check_init_spoolman(): return - async with self.cache_lock: - await self._initialize_mmu() - - if not gate_ids: - logging.error("Gate spool id mapping not provided or empty") - return False - - # Make sure we cleanup all the gate's old spool_id association - updates = {} - for gate, spool_id in gate_ids: - old_sids = self._find_all_spool_ids(self.printer_hostname, gate) - for old_sid in old_sids: - updates[old_sid] = -1 - - # Now layer in the supplied gate map - for gate, spool_id in gate_ids: - if spool_id > 0: - updates[spool_id] = gate - - # If setting a full gate map, include updates for "dirty" spool id's - # that are not otherwise going to be overwritten - if len(gate_ids) == self.nb_gates: - for spool_id, (p_name, gate, _) in self.spool_location.items(): - if p_name == self.printer_hostname and not any(s == spool_id for _, s in gate_ids): - updates[spool_id] = -1 - - # Create minimal set of async tasks to update Spoolman db and run them in parallel - tasks = { - sid: ( - self._unset_spool_gate(sid, silent=silent), - None - ) if updates[sid] < 0 else ( - self._set_spool_gate(sid, self.printer_hostname, updates[sid], silent=silent), - updates[sid] - ) - for sid in updates.keys() - } - results = await asyncio.gather(*[task for task,_ in tasks.values()]) - - # Log results and update cache - for sid, result in zip(tasks.keys(), results): - if result: - old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) - gate = tasks[sid][1] - if updates[sid] < 0: # 'unset' case - self.spool_location[sid] = ('', -1, filament_attr) - self.server.send_event("spoolman:unset_spool_gate", {"spool_id": sid, "printer": old_printer, "gate": old_gate}) - await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate} in Spoolman db", silent=silent) - else: # 'set' case - self.spool_location[sid] = (self.printer_hostname, gate, filament_attr) - self.server.send_event("spoolman:set_spool_gate", {"spool_id": sid, "printer": self.printer_hostname, "gate": gate}) - await self._log_n_send(f"Spool {sid} assigned to printer {self.printer_hostname} @ gate {gate} in Spoolman db", silent=silent) - - # Send update of filament attributes back to Happy Hare - return await self._send_gate_map_update(gate_ids, silent=silent) - - async def pull_gate_map(self, silent=False) -> bool: - ''' - Get all spools assigned to the current printer from Spoolman db and map them to gates - Pass back to Happy Hare - ''' - if not await self._check_init_spoolman(): return - async with self.cache_lock: - await self._initialize_mmu() - - gate_ids = [(gate, self._find_first_spool_id(self.printer_hostname, gate)) for gate in range(self.nb_gates)] - return await self._send_gate_map_update(gate_ids, replace=True, silent=silent) - - async def clear_spools_for_printer(self, printer=None, sync=False, silent=False) -> bool: - ''' - Clears all gates for the printer - ''' - if not await self._check_init_spoolman(): return - async with self.cache_lock: - await self._initialize_mmu() - - printer_name = printer or self.printer_hostname - if not silent: - logging.info(f"Clearing gate map for printer: {printer_name}") - - # Create minimal set of async tasks to update Spoolman db and run them in parallel - old_sids = self._find_all_spool_ids(printer_name, None) - tasks = {sid: self._unset_spool_gate(sid, silent=silent) for sid in old_sids} - results = await asyncio.gather(*tasks.values()) - - # Log results and update cache - updated_gate_ids = {} - for sid, result in zip(tasks.keys(), results): - if result: - old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) - if old_printer == self.printer_hostname and 0 <= old_gate < self.nb_gates and not updated_gate_ids.get(old_gate): - updated_gate_ids[old_gate] = -1 - self.spool_location[sid] = ('', -1, filament_attr) - self.server.send_event("spoolman:unset_spool_gate", {"spool_id": sid, "printer": old_printer, "gate": old_gate}) - await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate}", silent=silent) - - self.server.send_event("spoolman:clear_spool_gates", {"printer": printer_name}) - if sync and updated_gate_ids: - gate_ids = [(gate, spool_id) for gate, spool_id in updated_gate_ids.items()] - return await self._send_gate_map_update(gate_ids, replace=True, silent=silent) - return True - - async def set_spool_gate(self, spool_id=None, gate=None, sync=False, silent=False) -> bool: - ''' - Associate spool_id with the printer and gate and clear up any old associations - - parameters: - @param spool_id: id of the spool to set - @param gate: optional gate number to set the spool into. If not provided (and not an mmu), the spool will be set into gate 0. - returns: - @return: True if successful, False otherwise - Removes the printer + gate allocation in Spoolman db for gate (if supplied) - ''' - if not await self._check_init_spoolman(): return - async with self.cache_lock: - await self._initialize_mmu() - - # Sanity checking... - if gate is not None and gate < 0: - await self._log_n_send("Trying to set spool {spool_id} for printer {self.printer_hostname} but gate {gate} is invalid.", error=True, silent=silent) - return False - if gate is not None and gate > self.nb_gates - 1: - await self._log_n_send(f"Trying to set spool {spool_id} for printer {self.printer_hostname} @ gate {gate} but only {self.nb_gates} gates are available. Please check the spoolman or moonraker [spoolman] setup.", error=True, silent=silent) - return False - if gate is None: - if self.nb_gates: - await self._log_n_send(f"Trying to set spool {spool_id} for printer {self.printer_hostname} but printer has an MMU with {self.nb_gates} gates. Please check the spoolman or moonraker [spoolman] setup.", error=True, silent=silent) - return False - gate = 0 - - if not silent: - logging.info(f"Attempting to set gate {gate} for printer {self.printer_hostname}") - - # Create minimal set of async tasks to update Spoolman db and run them in parallel - old_sids = self._find_all_spool_ids(self.printer_hostname, gate) - tasks = { - sid: (self._unset_spool_gate(sid, silent=silent), None) - for sid in old_sids if sid != spool_id - } - tasks[spool_id] = (self._set_spool_gate(spool_id, self.printer_hostname, gate, silent=silent), gate) - results = await asyncio.gather(*[task for task,_ in tasks.values()]) - - # Log results and update cache - updated_gate_ids = {} - for sid, result in zip(tasks.keys(), results): - if result: - old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) - gate = tasks[sid][1] - if sid in old_sids and sid != spool_id: - # 'unset' case - if old_printer == self.printer_hostname and 0 <= old_gate < self.nb_gates and not updated_gate_ids.get(old_gate): - updated_gate_ids[old_gate] = -1 - self.spool_location[sid] = ('', -1, filament_attr) - self.server.send_event("spoolman:unset_spool_gate", {"spool_id": sid, "printer": old_printer, "gate": old_gate}) - await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate} in Spoolman db", silent=silent) - else: - # 'set' case - if 0 <= gate < self.nb_gates: - if old_printer == self.printer_hostname and 0 <= old_gate < self.nb_gates and not updated_gate_ids.get(old_gate): - updated_gate_ids[old_gate] = -1 - updated_gate_ids[gate] = sid - self.spool_location[sid] = (self.printer_hostname, gate, filament_attr) - self.server.send_event("spoolman:set_spool_gate", {"spool_id": sid, "printer": self.printer_hostname, "gate": gate}) - await self._log_n_send(f"Spool {sid} assigned to printer {self.printer_hostname} @ gate {gate} in Spoolman db", silent=silent) - - # Sync with Happy Hare if required - if sync and updated_gate_ids: - gate_ids = [(gate, spool_id) for gate, spool_id in updated_gate_ids.items()] - return await self._send_gate_map_update(gate_ids, replace=True, silent=silent) - return True - - async def unset_spool_gate(self, spool_id=None, gate=None, sync=False, silent=False) -> bool: - ''' - Removes the printer + gate allocation in Spoolman db for gate or spool_id (if supplied) - ''' - if not await self._check_init_spoolman(): return - async with self.cache_lock: - await self._initialize_mmu() - - # Sanity checking... - if spool_id is None and gate is None: - await self._log_n_send("Trying to unset spool but no spool_id or gate provided", error=True, silent=silent) - return False - if spool_id is not None and gate is not None: - await self._log_n_send(f"Trying to unset spool but both spool_id {spool_id} and gate {gate} provided. Only one or the other expected", error=True, silent=silent) - return False - if spool_id is not None: - if not self.spool_location.get(spool_id, ('', -1, {})): - await self._log_n_send(f"Trying to unset spool {spool_id} but not found in cache. Perhaps try refreshing cache", error=True, silent=silent) - return False - - # Create minimal set of async tasks to update Spoolman db and run them in parallel - sids = self._find_all_spool_ids(self.printer_hostname, gate) if gate is not None else [spool_id] - tasks = {sid: self._unset_spool_gate(sid, silent=silent) for sid in sids} - results = await asyncio.gather(*tasks.values()) - - # Log results and update cache - updated_gate_ids = {} - for sid, result in zip(tasks.keys(), results): - if result: - old_printer, old_gate, filament_attr = self.spool_location.get(sid, ('', -1, {})) - if old_printer == self.printer_hostname and 0 <= old_gate < self.nb_gates and not updated_gate_ids.get(old_gate): - updated_gate_ids[old_gate] = -1 - self.spool_location[sid] = ('', -1, filament_attr) - self.server.send_event("spoolman:unset_spool_gate", {"spool_id": sid, "old_printer": self.printer_hostname, "old_gate": gate}) - await self._log_n_send(f"Spool {sid} unassigned from printer {old_printer} and gate {old_gate} in Spoolman db", silent=silent) - - # Sync with Happy Hare if required - if sync and updated_gate_ids: - gate_ids = [(gate, spool_id) for gate, spool_id in updated_gate_ids.items()] - return await self._send_gate_map_update(gate_ids, replace=True, silent=silent) - return True - - async def display_spool_info(self, spool_id: int | None = None): - ''' - Gets info for active spool id and sends it to the klipper console. Does not require Spoolman db extension - ''' - async with self.cache_lock: - active = "Spool" - - if not spool_id: - logging.info("Fetching active spool") - spool_id = await self.spoolman.database.get_item(DB_NAMESPACE, ACTIVE_SPOOL_KEY, None) - active = "Active spool" - - if not spool_id: - msg = "No active spool set and no spool id supplied" - await self._log_n_send(msg, error=True) - return False - - spool_info = await self._fetch_spool_info(spool_id) - if not spool_info: - msg = f"Spool id {spool_id} not found" - await self._log_n_send(msg, error=True) - return False - - material = spool_info.get('material', "n/a") - used_weight = int(spool_info.get('used_weight', -1)) - f_used_weight = f"{used_weight} g" if used_weight >= 0 else "n/a" - remaining_weight = int(spool_info.get('remaining_weight', -1)) - f_remaining_weight = f"{remaining_weight} g" if remaining_weight >= 0 else "n/a" - msg = f"{active} is: {spool_info['filament']['name']} (id: {spool_info['id']})\n" - msg += f" - Material: {material}\n" - msg += f" - Used: {f_used_weight}\n" - msg += f" - Remaining: {f_remaining_weight}\n" - - # Check if spool_id is assigned - spool = next((gate for sid, (printer, gate, _) in self.spool_location.items() if spool_id == sid and self.printer_hostname == printer), None) - if spool is not None: - msg += f" - Gate: {spool}" - else: - msg += f"Spool id {spool_id} is not assigned to this printer!\n" - msg += f"Run: MMU_SPOOLMAN SPOOLID={spool_id} GATE=.. to add" - await self._log_n_send(msg) - return True - - async def display_spool_location(self, printer=None): - ''' - Builds a sorted table of gate to spool association for the specified printer and sends to klipper console - ''' - if not await self._check_init_spoolman(): return - async with self.cache_lock: - await self._initialize_mmu() - printer_name = printer or self.printer_hostname - filtered = sorted(((spool_id, gate) for spool_id, (printer, gate, _) in self.spool_location.items() if printer == printer_name), key=lambda x: x[1]) - if filtered: - msg = f"Spoolman gate assignment for printer: {printer_name}\n" - msg += "Gate | SpoolId\n" - msg += "-----+--------\n" - if self.nb_gates: - for mmu_gate in range(self.nb_gates): - sids = [spool_id for (spool_id, gate) in filtered if gate == mmu_gate] - sids_str = ",".join(map(str, sids)) - warning = " Error: Can only have a single spool assigned" if len(sids) > 1 else "" - msg += f"{mmu_gate:<5}| {sids_str}{warning}\n" - else: - # If not initialize_mmu() we will get here - for spool_id, gate in filtered: - msg += f"{gate:<5}| {spool_id}\n" - msg += "Run: MMU_SPOOLMAN REFRESH=1 to reset number of MMU gates" - else: - msg = f"No gates assigned for printer: {printer_name}" - await self._log_n_send(msg) - - async def push_lane_data(self, gate_ids): - ''' - Pushes lane data to Moonraker database for slicer integration (OrcaSlicer) - gate_ids: list of tuples [(gate, spool_id), ...] - ''' - try: - from datetime import datetime, timezone - - # Get MMU state from Klipper - mmu_state = await self.klippy_apis.query_objects({"mmu": None}) - mmu = mmu_state.get('mmu', {}) - - gate_material = mmu.get('gate_material', []) - gate_color = mmu.get('gate_color', []) - gate_temperature = mmu.get('gate_temperature', []) - gate_status = mmu.get('gate_status', []) - gate_filament_name = mmu.get('gate_filament_name', []) - - # Build batch of lane data - batch_data = {} - - for gate, spool_id in gate_ids: - if gate < 0: - continue - - # Lane uses same 0-based numbering as gate - lane = gate - lane_key = f"lane{lane}" - - # Check if gate is empty (status -1/unknown or 0/empty, or spool_id -1) - gate_status_val = gate_status[gate] if gate < len(gate_status) else -1 - is_empty = gate_status_val in [-1, 0] or spool_id == -1 - - if is_empty: - # Empty gate format - lane_data = { - "vendor_name": None, - "name": None, - "color": None, - "material": None, - "bed_temp": None, - "nozzle_temp": None, - "scan_time": None, - "td": None, - "lane": str(lane), - "spool_id": None, - "filament_id": None - } - else: - spool_attrs = self.spool_location.get(spool_id, ('', -1, {}))[2] if spool_id in self.spool_location else {} - # Populated gate format - lane_data = { - "vendor_name": spool_attrs.get('vendor', None) or None, - "name": (gate_filament_name[gate] if gate < len(gate_filament_name) else None) or spool_attrs.get('name', None) or None, - "color": gate_color[gate] if gate < len(gate_color) else None, - "td": None, # we don't currently capture transmision distance and isn't standard in spoolman - "material": gate_material[gate] if gate < len(gate_material) else None, - "bed_temp": spool_attrs.get('bed_temp', None) or None, - "nozzle_temp": gate_temperature[gate] if gate < len(gate_temperature) else 200, - "scan_time": None, - "lane": str(lane), # currently orca reads this as a string, but it is actually an int representing the gate number - "spool_id": spool_id if spool_id > 0 else None, - "filament_id": spool_attrs.get('filament_id', None) or None - } - - batch_data[lane_key] = lane_data - - # Push all lane data in a single batch - if batch_data: - await self.database.insert_batch("lane_data", batch_data) - - except Exception as e: - logging.error(f"Error pushing lane data: {e}") - - async def cleanup_lane_data(self, num_gates): - ''' - Removes lane data for gates that no longer exist (e.g., if MMU size was reduced) - num_gates: current number of gates in the MMU - ''' - try: - # Get all items in the lane_data namespace - lane_items = await self.database.get_item("lane_data", None, {}) - - # Delete lanes beyond the current num_gates (0-based: valid lanes are 0 to num_gates-1) - keys_to_delete = [] - for lane_key, lane_value in lane_items.items(): - # Extract lane number from the data object's lane field - if isinstance(lane_value, dict): - lane_str = lane_value.get('lane', '') - try: - lane_num = int(lane_str) - # If lane number >= num_gates, it's out of bounds, mark for deletion - if lane_num >= num_gates: - keys_to_delete.append(lane_key) - except (ValueError, TypeError): - continue - - # Delete the old lanes - await self.database.delete_batch("lane_data", keys_to_delete) - logging.info(f"Removed old lane data: {keys_to_delete}") - - except Exception as e: - logging.error(f"Error cleaning up lane data: {e}") - - - - # Switch out the metadata processor with this module which handles placeholders - def setup_placeholder_processor(self, config): - args = " -m" if config.getboolean("enable_file_preprocessor", True) else "" - args += " -n" if config.getboolean("enable_toolchange_next_pos", True) else "" - from .file_manager import file_manager - file_manager.METADATA_SCRIPT = os.path.abspath(__file__) + args - -def load_component(config): - return MmuServer(config) - - - -################################################################################## -# -# Beyond this point this module acts like an extended file_manager/metadata module -# -AUTHORZIED_SLICERS = ['PrusaSlicer', 'SuperSlicer', 'OrcaSlicer', 'BambuStudio'] - -HAPPY_HARE_FINGERPRINT = "; processed by HappyHare" -MMU_REGEX = r"^" + HAPPY_HARE_FINGERPRINT -SLICER_REGEX = r"^;.*generated by ([a-z]*) .*$|^; (BambuStudio) .*$" -ORCASLICER_VERSION_REGEX = r"^;\s*generated by OrcaSlicer\s+(\d+(?:\.\d+){0,3})" - -TOOL_DISCOVERY_REGEX = r"((^MMU_CHANGE_TOOL(_STANDALONE)? .*?TOOL=)|(^T))(?P\d{1,2})" - -METADATA_TOOL_DISCOVERY = "!referenced_tools!" -METADATA_TOTAL_TOOLCHANGES = "!total_toolchanges!" - -METADATA_BEGIN_PURGING = "CP TOOLCHANGE WIPE" -METADATA_END_PURGING = "CP TOOLCHANGE END" - -# PS/SS uses "extruder_colour", Orca uses "filament_colour" but extruder_colour can exist with empty or single color -COLORS_REGEX = { - 'PrusaSlicer' : r"^;\s*(?:extruder|filament)_colour\s*=\s*(#.*;*.*)$", #if extruder colour is not set, check filament colour - 'SuperSlicer' : r"^;\s*extruder_colour\s*=\s*(#.*;*.*)$", - 'OrcaSlicer' : r"^;\s*filament_colour\s*=\s*(#.*;*.*)$", - 'BambuStudio' : r"^;\s*filament_colour\s*=\s*(#.*;*.*)$", -} -METADATA_COLORS = "!colors!" - -TEMPS_REGEX = r"^;\s*(nozzle_)?temperature\s*=\s*(.*)$" # Orca Slicer/Bambu Studio has the 'nozzle_' prefix, others might not -METADATA_TEMPS = "!temperatures!" - -MATERIALS_REGEX = r"^;\s*filament_type\s*=\s*(.*)$" -METADATA_MATERIALS = "!materials!" - -PURGE_VOLUMES_REGEX = r"^;\s*(flush_volumes_matrix|wiping_volumes_matrix)\s*=\s*(.*)$" # flush.. in Orca/Bambu, wiping... in PS -METADATA_PURGE_VOLUMES = "!purge_volumes!" - -FLUSH_MULTIPLIER_REGEX = r"^;\s*flush_multiplier\s*=\s*(.*)$" #flush multiplier in Orca/Bambu. Used to multiply the values in the purge volumes to match the slicer UI settings - -FILAMENT_NAMES_REGEX = r"^;\s*(filament_settings_id)\s*=\s*(.*)$" -METADATA_FILAMENT_NAMES = "!filament_names!" - -# Detection for next pos processing -T_PATTERN = r'^T(\d+)\s*(?:;.*)?$' -G1_PATTERN = r'^G[01](?=.*\sX(-?[\d.]+))(?=.*\sY(-?[\d.]+)).*$' - -def _parse_version_tuple(version_str: str, parts: int = 3): - """Parse a version like '2.3.2-dev'/'2.3.2' into a comparable tuple (2, 3, 2). - - Only the numeric dot-separated prefix is considered; missing parts are padded with zeros. - """ - if not version_str: - return None - m = re.match(r"^\s*(\d+(?:\.\d+)*)", version_str) - if not m: - return None - nums = m.group(1).split(".") - out = [] - for s in nums[:parts]: - try: - out.append(int(s)) - except ValueError: - out.append(0) - while len(out) < parts: - out.append(0) - return tuple(out) - -def _format_volume(v: float) -> str: - """Format a purge volume number without trailing .0, keeping up to 1 decimal place.""" - v = round(float(v), 1) - s = f"{v:.1f}" - return s.rstrip("0").rstrip(".") - -def gcode_processed_already(file_path): - """Expects first line of gcode to be the HAPPY_HARE_FINGERPRINT '; processed by HappyHare'""" - - mmu_regex = re.compile(MMU_REGEX, re.IGNORECASE) - - with open(file_path, 'r') as in_file: - line = in_file.readline() - return mmu_regex.match(line) - -def parse_gcode_file(file_path): - slicer_regex = re.compile(SLICER_REGEX, re.IGNORECASE) - orca_version_regex = re.compile(ORCASLICER_VERSION_REGEX, re.IGNORECASE) - has_tools_placeholder = has_total_toolchanges = has_colors_placeholder = has_temps_placeholder = has_materials_placeholder = has_purge_volumes_placeholder = filament_names_placeholder = False - found_colors = found_temps = found_materials = found_purge_volumes = found_filament_names = found_flush_multiplier = False - slicer = None - orca_version = None - - tools_used = set() - total_toolchanges = 0 - colors = [] - temps = [] - materials = [] - purge_volumes = [] - filament_names = [] - flush_multiplier = 1.0 # Initialize flush_multiplier to 1.0 - - with open(file_path, 'r') as in_file: - for line in in_file: - if not line.startswith(";"): - continue - - # Discover slicer - if not slicer: - match = slicer_regex.match(line) - if match: - slicer = match.group(1) or match.group(2) - - # Capture OrcaSlicer version (numeric prefix only, e.g. 2.3.2 from 2.3.2-dev) - if orca_version is None: - mver = orca_version_regex.match(line) - if mver: - orca_version = _parse_version_tuple(mver.group(1)) - if slicer in AUTHORZIED_SLICERS: - if isinstance(TOOL_DISCOVERY_REGEX, dict): - tools_regex = re.compile(TOOL_DISCOVERY_REGEX[slicer], re.IGNORECASE) - else: - tools_regex = re.compile(TOOL_DISCOVERY_REGEX, re.IGNORECASE) - if isinstance(COLORS_REGEX, dict): - colors_regex = re.compile(COLORS_REGEX[slicer], re.IGNORECASE) - else: - colors_regex = re.compile(COLORS_REGEX, re.IGNORECASE) - if isinstance(TEMPS_REGEX, dict): - temps_regex = re.compile(TEMPS_REGEX[slicer], re.IGNORECASE) - else: - temps_regex = re.compile(TEMPS_REGEX, re.IGNORECASE) - if isinstance(MATERIALS_REGEX, dict): - materials_regex = re.compile(MATERIALS_REGEX[slicer], re.IGNORECASE) - else: - materials_regex = re.compile(MATERIALS_REGEX, re.IGNORECASE) - if isinstance(PURGE_VOLUMES_REGEX, dict): - purge_volumes_regex = re.compile(PURGE_VOLUMES_REGEX[slicer], re.IGNORECASE) - else: - purge_volumes_regex = re.compile(PURGE_VOLUMES_REGEX, re.IGNORECASE) - if isinstance(FILAMENT_NAMES_REGEX, dict): - filament_names_regex = re.compile(FILAMENT_NAMES_REGEX[slicer], re.IGNORECASE) - else: - filament_names_regex = re.compile(FILAMENT_NAMES_REGEX, re.IGNORECASE) - - if isinstance(FLUSH_MULTIPLIER_REGEX, dict): - flush_multiplier_regex = re.compile(FLUSH_MULTIPLIER_REGEX[slicer], re.IGNORECASE) - else: - flush_multiplier_regex = re.compile(FLUSH_MULTIPLIER_REGEX, re.IGNORECASE) - - with open(file_path, 'r') as in_file: - for line in in_file: - # !referenced_tools! and !total_toolchanges! processing - if not has_tools_placeholder and METADATA_TOOL_DISCOVERY in line: - has_tools_placeholder = True - - if not has_total_toolchanges and METADATA_TOTAL_TOOLCHANGES in line: - has_total_toolchanges = True - - match = tools_regex.match(line) - if match: - tool = match.group("tool") - tools_used.add(int(tool)) - total_toolchanges += 1 - - # !colors! processing - if not has_colors_placeholder and METADATA_COLORS in line: - has_colors_placeholder = True - - if not found_colors: - match = colors_regex.match(line) - if match: - colors_csv = [color.strip().lstrip('#') for color in match.group(1).split(';')] - if not colors: - colors.extend(colors_csv) - else: - colors = [n if o == '' else o for o,n in zip(colors,colors_csv)] - found_colors = all(len(c) > 0 for c in colors) - - # !temperatures! processing - if not has_temps_placeholder and METADATA_TEMPS in line: - has_temps_placeholder = True - - if not found_temps: - match = temps_regex.match(line) - if match: - temps_csv = re.split(';|,', match.group(2).strip()) - temps.extend(temps_csv) - found_temps = True - - # !materials! processing - if not has_materials_placeholder and METADATA_MATERIALS in line: - has_materials_placeholder = True - - if not found_materials: - match = materials_regex.match(line) - if match: - materials_csv = match.group(1).strip().split(';') - materials.extend(materials_csv) - found_materials = True - - # flush_multiplier processing - if not found_flush_multiplier: - match = flush_multiplier_regex.match(line) - if match: - try: - flush_multiplier = float(match.group(1).strip()) - except ValueError: - flush_multiplier = 1.0 # Default to 1.0 if conversion fails - found_flush_multiplier = True - - # !purge_volumes! processing - if not has_purge_volumes_placeholder and METADATA_PURGE_VOLUMES in line: - has_purge_volumes_placeholder = True - - if not found_purge_volumes: - match = purge_volumes_regex.match(line) - if match: - purge_volumes_csv = [v.strip() for v in match.group(2).strip().split(',')] - - # OrcaSlicer 2.3.2+ already bakes flush_multiplier into the flush_volumes_matrix. - # OrcaSlicer <=2.3.1 requires applying flush_multiplier here to match the UI. - apply_flush_multiplier = True - if slicer == "OrcaSlicer" and orca_version is not None and orca_version >= (2, 3, 2): - apply_flush_multiplier = False - - for volume_str in purge_volumes_csv: - # If we shouldn't apply multiplier, keep the raw value as-is (preserves integer formatting). - if not apply_flush_multiplier or flush_multiplier == 1.0: - purge_volumes.append(volume_str) - continue - try: - volume = float(volume_str) - multiplied_volume = volume * flush_multiplier - purge_volumes.append(_format_volume(multiplied_volume)) - except ValueError: - # If conversion fails, keep the original value - purge_volumes.append(volume_str) - found_purge_volumes = True - - # !filament_names! processing - if not filament_names_placeholder and METADATA_FILAMENT_NAMES in line: - filament_names_placeholder = True - - if not found_filament_names: - match = filament_names_regex.match(line) - if match: - filament_names_csv = [e.strip() for e in re.split(',|;', match.group(2).strip())] - filament_names.extend(filament_names_csv) - found_filament_names = True - - return (has_tools_placeholder or has_total_toolchanges or has_colors_placeholder or has_temps_placeholder or has_materials_placeholder or has_purge_volumes_placeholder or filament_names_placeholder, - sorted(tools_used), total_toolchanges, colors, temps, materials, purge_volumes, filament_names, slicer) - -def process_file(input_filename, output_filename, insert_nextpos, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names): - - t_pattern = re.compile(T_PATTERN) - g1_pattern = re.compile(G1_PATTERN) - - with open(input_filename, 'r') as infile, open(output_filename, 'w') as outfile: - buffer = [] # Buffer lines between a "T" line and the next matching "G1" line - tool = None # Store the tool number from a "T" line - outfile.write(f'{HAPPY_HARE_FINGERPRINT}\n') - - for line in infile: - line = add_placeholder(line, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names) - if tool is not None: - # Buffer subsequent lines after a "T" line until next "G1" x,y move line is found - buffer.append(line) - g1_match = g1_pattern.match(line) - if g1_match: - # Now replace "T" line and write buffered lines, including the current "G1" line - if insert_nextpos: - x, y = g1_match.groups() - outfile.write(f'MMU_CHANGE_TOOL TOOL={tool} NEXT_POS="{x},{y}" ; T{tool}\n') - else: - outfile.write(f'MMU_CHANGE_TOOL TOOL={tool} ; T{tool}\n') - for buffered_line in buffer: - outfile.write(buffered_line) - buffer.clear() - tool = None - continue - - t_match = t_pattern.match(line) - if t_match: - tool = t_match.group(1) - else: - outfile.write(line) - - # If there is anything left in buffer it means there wasn't a final "G1" line - if buffer: - outfile.write(f"T{tool}\n") - outfile.write(f'MMU_CHANGE_TOOL TOOL={tool} ; T{tool}\n') - for line in buffer: - outfile.write(line) - - # Finally append "; referenced_tools =" as new metadata (why won't Prusa pick up my PR?) - outfile.write("; referenced_tools = %s\n" % ",".join(map(str, tools_used))) - -def add_placeholder(line, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names): - # Ignore comment lines to preserve slicer metadata comments - if not line.startswith(";"): - if METADATA_TOOL_DISCOVERY in line: - if tools_used: - line = line.replace(METADATA_TOOL_DISCOVERY, ",".join(map(str, tools_used))) - else: - line = line.replace(METADATA_TOOL_DISCOVERY, "0") - if METADATA_TOTAL_TOOLCHANGES in line: - line = line.replace(METADATA_TOTAL_TOOLCHANGES, str(total_toolchanges)) - if METADATA_COLORS in line: - line = line.replace(METADATA_COLORS, ",".join(map(str, colors))) - if METADATA_TEMPS in line: - line = line.replace(METADATA_TEMPS, ",".join(map(str, temps))) - if METADATA_MATERIALS in line: - line = line.replace(METADATA_MATERIALS, ",".join(map(str, materials))) - if METADATA_PURGE_VOLUMES in line: - line = line.replace(METADATA_PURGE_VOLUMES, ",".join(map(str, purge_volumes))) - if METADATA_FILAMENT_NAMES in line: - line = line.replace(METADATA_FILAMENT_NAMES, ",".join(map(str, filament_names))) - else: - if METADATA_BEGIN_PURGING in line: - line = line + "_MMU_STEP_SET_ACTION STATE=12\n" - elif METADATA_END_PURGING in line: - line = line + "_MMU_STEP_SET_ACTION RESTORE=1\n" - return line - -def main(path, filename, insert_placeholders=False, insert_nextpos=False): - file_path = os.path.join(path, filename) - if not os.path.isfile(file_path): - metadata.logger.info(f"File Not Found: {file_path}") - sys.exit(-1) - try: - metadata.logger.info(f"mmu_server: Pre-processing file: {file_path}") - fname = os.path.basename(file_path) - if fname.endswith(".gcode") and not gcode_processed_already(file_path): - with tempfile.TemporaryDirectory() as tmp_dir_name: - tmp_file = os.path.join(tmp_dir_name, fname) - - if insert_placeholders: - start = time.time() - has_placeholder, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names, slicer = parse_gcode_file(file_path) - metadata.logger.info("Reading placeholders took %.2fs. Detected gcode by slicer: %s" % (time.time() - start, slicer)) - else: - tools_used = total_toolchanges = colors = temps = materials = purge_volumes = filament_names = slicer = None - has_placeholder = False - - if (insert_nextpos and tools_used is not None and len(tools_used) > 0) or has_placeholder: - start = time.time() - msg = [] - if has_placeholder: - msg.append("Writing MMU placeholders") - if insert_nextpos: - msg.append("Inserting next position to tool changes") - process_file(file_path, tmp_file, insert_nextpos, tools_used, total_toolchanges, colors, temps, materials, purge_volumes, filament_names) - metadata.logger.info("mmu_server: %s took %.2fs" % (",".join(msg), time.time() - start)) - - # Move temporary file back in place - if os.path.islink(file_path): - file_path = os.path.realpath(file_path) - if not filecmp.cmp(tmp_file, file_path): - shutil.move(tmp_file, file_path) - else: - metadata.logger.info(f"Files are the same, skipping replacement of: {file_path} by {tmp_file}") - else: - metadata.logger.info(f"No MMU metadata placeholders found in file: {file_path}") - - except Exception: - metadata.logger.info(traceback.format_exc()) - sys.exit(-1) - -# When run separately this module wraps metadata to extend pre-processing functionality -if __name__ == "__main__": - # Make it look like we are running in the file_manager directory - directory = os.path.dirname(os.path.abspath(__file__)) - target_dir = directory + "/file_manager" - os.chdir(target_dir) - sys.path.insert(0, target_dir) - - import metadata - metadata.logger.info("mmu_server: Running MMU enhanced version of metadata") - - # We need to re-parse arguments anyway, so this way, whilst relaxing need to copy code, isn't useful - #runpy.run_module('metadata', run_name="__main__", alter_sys=True) - - # Parse start arguments (copied from metadata.py) - parser = argparse.ArgumentParser(description="GCode Metadata Extraction Utility") - parser.add_argument("-c", "--config", metavar='', default=None, help="Optional json configuration file for metadata.py") - parser.add_argument("-f", "--filename", metavar='', help="name gcode file to parse") - parser.add_argument("-p", "--path", default=os.path.abspath(os.path.dirname(__file__)), metavar='', help="optional absolute path for file") - parser.add_argument("-u", "--ufp", metavar="", default=None, help="optional path of ufp file to extract") - parser.add_argument("-o", "--check-objects", dest='check_objects', action='store_true', help="process gcode file for exclude object functionality") - parser.add_argument("-m", "--placeholders", dest='placeholders', action='store_true', help="process happy hare mmu placeholders") - parser.add_argument("-n", "--nextpos", dest='nextpos', action='store_true', help="add next position to tool change") - args = parser.parse_args() - config: Dict[str, Any] = {} - if args.config is None: - if args.filename is None: - metadata.logger.info( - "The '--filename' (-f) option must be specified when " - " --config is not set" - ) - sys.exit(-1) - config["filename"] = args.filename - config["gcode_dir"] = args.path - config["ufp_path"] = args.ufp - config["check_objects"] = args.check_objects - else: - # Config file takes priority over command line options - try: - with open(args.config, "r") as f: - config = (json.load(f)) - except Exception: - metadata.logger.info(traceback.format_exc()) - sys.exit(-1) - if config.get("filename") is None: - metadata.logger.info("The 'filename' field must be present in the configuration") - sys.exit(-1) - if config.get("gcode_dir") is None: - config["gcode_dir"] = os.path.abspath(os.path.dirname(__file__)) - enabled_msg = "enabled" if config["check_objects"] else "disabled" - metadata.logger.info(f"Object Processing is {enabled_msg}") - - # Parsing for mmu placeholders and next pos insertion. We do this first so we can add additonal metadata - main(config["gcode_dir"], config["filename"], args.placeholders, args.nextpos) - - # Original metadata parser - metadata.main(config) diff --git a/klippy/extras/Happy-Hare/config/addons/README.md b/klippy/extras/Happy-Hare/config/addons/README.md deleted file mode 100644 index be3d5ddd80e9..000000000000 --- a/klippy/extras/Happy-Hare/config/addons/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Addons -This directory contains recommended optional addons for your MMU setup. See the doc in the [Happy Hare Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Addon-Feature-Setup) - -
- -> [!IMPORTANT] -> For all add-on extensions, ensure that you always use the "cfg" files from Happy Hare and not those sourced elsewhere so you have the most recent changes and fixes. diff --git a/klippy/extras/Happy-Hare/config/addons/blobifier.cfg b/klippy/extras/Happy-Hare/config/addons/blobifier.cfg deleted file mode 100644 index 6c29fea61782..000000000000 --- a/klippy/extras/Happy-Hare/config/addons/blobifier.cfg +++ /dev/null @@ -1,1053 +0,0 @@ -# Include servo hardware definition separately to allow for automatic upgrade -[include blobifier_hw.cfg] - -########################################################################################## - -# Sample config to be used in conjunction with Blobifier Purge Tray, Bucket & Nozzle -# Scrubber mod. Created by Dendrowen (dendrowen on Discord). The Macro is based on a -# version, and Nozzle Scrubber is made by Hernsl (hernsl#8860 on Discord). The device is -# designed around a Voron V2.4 300mm, but should work for 250mm and 350mm too. This -# version only supports the assembly on the rear-left of the bed. If you decide to change -# that, please consider contributing to the project by creating a pull request with the -# needed changes. - -# IMPORTANT: The rear-left part of your bed becomes unusable by this mod because the -# toolhead needs to lower down to 0. Be sure not to use the left-rear 130x35mm. - -# The goals of this combination of devices is to dispose of purged filament during a -# multicolored print without the need of a purge block and without the flurries of -# filament poops consuming your entire 3D printer room. The Blobifier achieves that by -# purging onto a retractable tray which causes the filament to turn into a tiny blob -# rather then a large spiral. This keeps the waste relatively small. The bucket should be -# able to account for up to 200 filament swaps (for the 300mm V2). - -# The Blobifier uses some room at the back-left side of your printer, depending on your -# printer limits and positions. (usually max_pos.y - toolhead_y and brush_start + -# brush_width + toolhead_x). If you do place objects within this region, Blobifier will -# skip purging automatically. It does this by extending the EXCLUDE_OBJECT_* macro's, so -# make sure you have exclude objects enabled in your slicer. - -# If your using Blobifier in conjunction with the filament cutter on the stealthburner -# toolhead, you can place the pin at max_pos.y - 7 (e.g., max pos y is 307, place it at -# 300). The pin will then poke through the cavity in your toolhead. (Be careful with -# manually moving the toolhead. I have broken many filament cutter pins) - -# It is advised to use the start_gcode from Happy Hare. Then you will be able to fully -# and efficiently use this mod. Check the Happy Hare document at gcode_preprocessing.md -# in the Happy Hare github for more details. - -###################################### DISCLAIMER ######################################## - -# You, and you alone, are responsible for the correct execution of these macros and -# gcodes. Any damage that may occur to your machine remains your responsibility. -# Especially when executing this macro for the first few times, keep an eye on your -# printer and the -# emergency stop. - -########################################################################################## - -########################################################################################## -# Main macro. Call this from the purge_macro setting in 'mmu_parameters.cfg'. Previously -# it was suggested to hook in via the post_load macro, but that method is now deprecated -# and can lead to synchroniztion/servo movement issues. -# -# purge_macro : `BLOBIFIER` -# -# Notes on parameters: -# PURGE_LENGTH=[float] (optional) The length to purge. If omitted (default) it will check -# the purge_volumes matrix or variable_purge_length. This can be used -# to override and for testing. -# -[gcode_macro BLOBIFIER] -# These parameters define your filament purging. -# Note that the control of retraction is set in 'mmu_macro_vars.cfg' which can be increased -# if you experience excessive oozing. -variable_purge_spd: 400 # Speed, in mm/min, of the purge. -variable_purge_temp_min: 200 # Minimum nozzle purge temperature. -variable_toolhead_x: 70 # From the nozzle to the left of your toolhead -variable_toolhead_y: 50 # From the nozzle to the front of your toolhead - -# This macro will prevent a gcode movement downward while 'blobbing' if there might be a -# print in the way (e.g. You print something large and need the area where Blobifier does -# its... 'business'). However, at low heights (or at print start) this might not be -# desireable. You can force a 'safe descend' with this variable. Keep in mind that the -# height of the print is an estimation based on previous heights and certain assumptions -# so it might be wise to include a safety margin of 0.2mm -variable_force_safe_descend_height_until: 1.0 - -# Adjust this so that your nozzle scrubs within the brush. Be careful not to go too low! -# Start out with a high value (like, 6) and go down from there. -# Set this to None if you have a gantry mounted brush and do not want move Z axis for brushing. -variable_brush_top: 6 - -# These parameters define your scrubbing, travel speeds, safe z clearance and how many -# times you want to wipe. Update as necessary. -variable_clearance_z: 2 # When traveling, but not cleaning, the - # clearance along the z-axis between nozzle - # and brush. -variable_wipe_qty: 2 # Number of complete (A complete wipe: left, - # right, left OR right, left, right) wipes. -variable_travel_spd_xy: 10000 # Travel (not cleaning) speed along x and - # y-axis in mm/min. -variable_travel_spd_z: 1000 # Travel (not cleaning) speed along z axis - # in mm/min. -variable_wipe_spd_xy: 10000 # Nozzle wipe speed in mm/min. - -# The acceleration to use when using the brush action. If set to 0, it uses the already -# set acceleration. However, in some cases this is not desirable for the last motion -# could be an 'outer contour' acceleration which is usually lower. -variable_brush_accel: 0 - -# Blobifier sends the toolhead to the maximum y position during purge operations and -# minimum x position during shake operations. This can cause issues when skew correction -# is set up. If you have skew correction enabled and get 'move out of range' errors -# regarding blobifier while skew is enabled, try increasing this value. Keep the -# adjustments small though! (0.1mm - 0.5mm) and increase it until it works. -variable_skew_correction: 0.1 - -# offset subtracted from the Y max position to position the toolhead during purging. -variable_y_offset: 0 - -# These parameters define the size of the brush. Update as necessary. A visual reference -# is provided below. -# -# ← brush_width → -# _________________ -# | | ↑ Y position is acquired from your -# brush_start (x) | | brush_depth stepper_y position_max. Adjust -# |_________________| ↓ your brush physically in Y so -# (y) that the nozzle scrubs within the -# brush_front brush. -# __________________________________________________________ -# PRINTER FRONT -# -# -# Start location of the brush. Defaults for 250, 300 and 350mm are provided below. -# Uncomment as necessary -#variable_brush_start: 34 # For 250mm build -variable_brush_start: 67 # For 300mm build -#variable_brush_start: 84 # for 350mm build - -# width of the brush -variable_brush_width: 35 - -# offset subtracted from the y max to perform brushing. -variable_brush_y_offset: 0 - -# Location of where to purge. The tray is 15mm in length, so if you assemble it against -# the side of the bed (default), 10mm is a good location -variable_purge_x: 10 - -# Height of the tray. If it's below your bed, give this a negative number equal to the -# difference. If it's above your bed, give it a positive number. You can find this number -# by homing, optional QGL or equivalent, and moving you toolhead above the tray, and -# lowering it with the paper method. -variable_tray_top: 0.7 - -# Servo angles for tray positions -variable_tray_angle_out: 0 -variable_tray_angle_in: 180 - -# Increase this value if the servo doesn't have enough time to fully retract or extend -variable_dwell_time: 200 - -# ======================================================================================== -# ==================== BLOB TUNING ======================================================= -# ======================================================================================== - -# The following section defines how the purging sequence is executed. This is where you -# tune the purging to create pretty blobs. Refer to the visual reference for a better -# understanding. The visual is populated with example values. Below are some guides -# provided to help with tuning. -# -# \_____________/ -# |___|___| -# \_/ ______________ < End of third iteration. -# / \ HEIGHT: 3 x iteration_z_raise - (2 + 1) x iteration_z_change (3 x 5 - 2 x 1.2 = 11.4) -# | | EXTRUDED: 3 x max_iteration_length (3 x 50 = 150) -# / \ ______________ < End of second iteration. -# | \ HEIGHT: 2 x iteration_z_raise - 1 x iteration_z_change (2 x 5 - 1 x 1.2 = 8.8) -# / | EXTRUDED: 2 x max_iteration_length (2 x 50 = 100) -# | \ ______________ < End of first iteration. -# / \ HEIGHT: 1 x iteration_z_raise (1 x 5 = 5) -# | | EXTRUDED: 1 x max_iteration_length (1 x 50 = 50) -#___________ \ / ______________ < Start height of the nozzle. default value: 1.5mm -# |_______________\___________/_ ______________ < Bottom of the tray -# |_____________________________| -# | -# -########################### BLOB TUNING ############################## -# +-------------------------------------+----------------------------+ -# | Filament sticks to the nozzle at | Incr. purge start | -# | initial purge (first few mm) | | -# +-------------------------------------+----------------------------+ -# | Filament scoots out from under | Incr. temperature | -# | the nozzle at the first iteration | Decr. z_raise | -# | | Incr. purge_length_maximum | -# +-------------------------------------+----------------------------+ -# | Filament scoots out from under the | Decr. purge_spd | -# | the nozzle at later iterations | Decr. z_raise_exp | -# | | Decr. z_raise | -# | | Incr. purge_length_maximum | -# +-------------------------------------+----------------------------+ -# | Filament sticks to the nozzle at | Incr. z_raise_exp | -# | later iterations | (Not above 1) | -# +-------------------------------------+----------------------------+ -# - -# The height to raise the nozzle above the tray before purging. This allows any built up -# pressure to escape before the purge. -variable_purge_start: 0.2 - -# The amount to raise Z -variable_z_raise: 12 - -# As the nozzle gets higher and the blob wider, the Z raise needs to be reduced, this -# follows the following formula: -# (extruded_amount/max_purge_length)^z_raise_exp * z_raise -# 1 is linear, below 1 will cause z to raise less quickly over time, above 1 will make it -# raise quicker over time. 0.85 is a good starting point and you should not have it above 1 -variable_z_raise_exp: 0.85 - -# Lift the nozzle slightly after creating the blob te release pressure on the tray. -variable_eject_hop: 1.0 - -# Dwell time (ms) after purging and before cleaning to relieve pressure from the nozzle. -variable_pressure_release_time: 1000 - -# Amount (mm) to retract between blobs to reduce string formation and allow for a cleaner purge. -variable_retract_between_blobs: 2 - -# Set the part cooling fan speed during purging (variable_part_cooling_fan) and -# during the blob depositing process (variable_part_cooling_fan_blob_deposit) -# Low fan speeds during purging help ensure the pellets are well formed and reduces -# the probability of the pellets coming unstuck from tray due to material shrinkage. -# Setting the blob depositing fan speed to high (100%) helps freeze the blob, unstick -# the blob from the nozzle and shrink it a little allowing for a cleaner blob depositing - -variable_part_cooling_fan: 0.3 # Fan speed to use during purging (-1 is unchanged, 0 is off, 0.1-1.0 for fan speed %) -variable_part_cooling_fan_blob_deposit: 1 # Fan speed to use during blob depositing (-1 is unchanged, 0 is off, 0.1-1.0 for fan speed % - -# Define the part fan name if you are using a fan other than [fan] -# Applies to [fan_generic] or other fan definitons -# Example would be if you are using auxiliary fan control in Orcaslicer (https://github.com/SoftFever/OrcaSlicer/wiki/Auxiliary-fan) -# If you are unsure if you need this, then probably just leave it commented out. - -#variable_fan_name: "fan_generic fan0" - - - -# ======================================================================================== -# ==================== PURGE LENGTH TUNING =============================================== -# ======================================================================================== - -# The absolute minimum to purge, even if you don't changed tools. This is to prime the -# nozzle before printing -variable_purge_length_minimum: 30 - -# The maximum amount of filament (in mm¹) to purge in a single blob. Blobifier will -# automatically purge multiple blobs if the purge amount exceeds this. -variable_purge_length_maximum: 150 - -# Default purge length to fall back on when neither the tool map purge_volumes or -# parameter PURGE_LENGTH is set. -variable_purge_length: 150 - -# The slicer values often are a bit too wasteful. Tune it here to get optimal values. -# 0.6 (60%) is a good starting point. -variable_purge_length_modifier: 0.6 - -# Fixed length of filament to add after the purge volume calculation. Happy Hare already -# shares info on the extra amount of filament to purge based on known residual filament, -# tip cutting fragment and initial retraction setting. However this setting can add a fixed -# amount on top on that if necessary although it is recommended to start with 0 and tune -# slicer purge matrix first. -# When should you alter this value: -# INCREASE: When the dark to light swaps are good, but light to dark aren't. -# DECREASE: When the light to dark swaps are good, but dark to light aren't. Don't -# forget to increase the purge_length_modifier -variable_purge_length_addition: 0 - -# ======================================================================================== -# ==================== BUCKET ============================================================ -# ======================================================================================== - -# Maximum number of blobs that fit in the bucket. Pauses the print if it exceeds this -# number. -variable_max_blobs: 400 -# Enable the bucket shaker. You need to have the shaker.stl installed -variable_enable_shaker: 1 -# Shaker position offset in X (relative to zero) -# Allows negative positions. Add skew_correction separately if needed. -variable_shaker_pos_x: 0 -# The number of back-and-forth motions of one shake -variable_bucket_shakes: 10 -# During shaking acceleration can often be higher because you don't need to keep print -# quality in mind. Higher acceleration helps better with dispersing the blobs. -variable_shake_accel: 10000 - -# The frequency at which to shake the bucket. A decimal value ranging from 0 to 1, where 0 -# is never, and 1 is every time. This way the shaking occurs more often as the bucket -# fills up. Sensible values range from 0.75 to 0.95 -variable_bucket_shake_frequency: 0.95 - -# Height of the shaker arm. If your hotend hits your tray during shaking, increase. -variable_shaker_arm_z: 2 - -gcode: - - # ====================================================================================== - # ==================== RECORD STATE (INCL. FANS, SPEEDS, ETC...) ======================= - # ====================================================================================== - - # General state - SAVE_GCODE_STATE NAME=BLOBIFIER_state - - - # ====================================================================================== - # ==================== CHECK HOMING STATUS ============================================= - # ====================================================================================== - - {% if "xyz" not in printer.toolhead.homed_axes %} - RESPOND MSG="BLOBIFIER: Not homed! Home xyz before blobbing" - {% elif printer.quad_gantry_level and printer.quad_gantry_level.applied == False %} - RESPOND MSG="BLOBIFIER: QGL not applied! run quad_gantry_level before blobbing" - {% else %} - - - # Always backup part cooling fan speed - {% set fan = fan_name|string %} - # Save the part cooling fan speed to be enabled again later - {% set backup_fan_speed = (printer[fan].speed if printer[fan] is defined else printer.fan.speed) %} - - # Part cooling fan set for purging - {% if part_cooling_fan >= 0 %} - # Set part cooling fan speed - M106 S{part_cooling_fan * 255} - {% endif %} - - # Set feedrate to 100% for correct speed purging - {% set backup_feedrate = printer.gcode_move.speed_factor %} - M220 S100 - - # Backup and zero Pressure Advance during blobbing - {% set backup_pa = printer.extruder.pressure_advance|default(0.0)|float %} - RESPOND MSG={"'BLOBIFIER: Setting PA to zero (was %.4f)'" % (backup_pa)} - SET_PRESSURE_ADVANCE ADVANCE=0 - - - # ====================================================================================== - # ==================== DEFINE BASIC VARIABLES ========================================== - # ====================================================================================== - - {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set filament_diameter = printer.configfile.config.extruder.filament_diameter|float %} - {% set filament_cross_section = (filament_diameter/2) ** 2 * 3.1415 %} - {% set from_tool = printer.mmu.last_tool %} - {% set to_tool = printer.mmu.tool %} - {% set bl_count = printer['gcode_macro _BLOBIFIER_COUNT'] %} - {% set pos = printer.gcode_move.gcode_position %} - {% set safe = printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'] %} - {% set ignore_safe = safe.print_height < force_safe_descend_height_until %} - {% set restore_z = [printer['gcode_macro BLOBIFIER_PARK'].restore_z,pos.z]|max %} - {% set pos_max = printer.toolhead.axis_maximum %} - {% set position_y = pos_max.y - skew_correction - y_offset %} - - # Get purge volumes from the slicer (if set up right. see - # https://github.com/moggieuk/Happy-Hare/wiki/Gcode-Preprocessing) - {% set pv = printer.mmu.slicer_tool_map.purge_volumes %} - - # ====================================================================================== - # ==================== DETERMINE PURGE LENGTH ========================================== - # ====================================================================================== - - {% if params.PURGE_LENGTH %} # =============== PARAM PURGE LENGTH ====================== - {action_respond_info("BLOBIFIER: param PURGE_LENGTH provided")} - {% set purge_len = params.PURGE_LENGTH|float %} - {% elif from_tool == to_tool and to_tool >= 0 %} # ==== TOOL DIDN'T CHANGE ============= - {action_respond_info("BLOBIFIER: Tool didn't change (T%s > T%s), %s" % (from_tool, to_tool, "priming" if purge_length_minimum else "skipping"))} - {% set purge_len = 0 %} - - {% elif pv %} # ============== FETCH FROM HAPPY HARE (LIKELY FROM SLICER) ============== - {% if from_tool < 0 and to_tool >= 0%} - {action_respond_info("BLOBIFIER: from tool unknown. Finding largest value for T? > T%d" % to_tool)} - {% set purge_vol = pv|map(attribute=to_tool)|max %} - {% elif to_tool < 0 %} - {action_respond_info("BLOBIFIER: tool(s) unknown. Finding largest value")} - {% set purge_vol = pv|map('max')|max %} - {% else %} - {% set purge_vol = pv[from_tool][to_tool]|float * purge_length_modifier %} - {action_respond_info("BLOBIFIER: Swapped T%s > T%s" % (from_tool, to_tool))} - {% endif %} - {% set purge_len = purge_vol / filament_cross_section %} - - {% set purge_len = purge_len + printer.mmu.extruder_filament_remaining + park_vars.retracted_length + purge_length_addition %} - - {% else %} # ========================= USE CONFIG VARIABLE ============================= - {action_respond_info("BLOBIFIER: No toolmap or PURGE_LENGTH. Using default")} - {% set purge_len = purge_length|float + printer.mmu.extruder_filament_remaining + park_vars.retracted_length %} - {% endif %} - - # ==================================== APPLY PURGE MINIMUM ============================= - {% set purge_len = [purge_len,purge_length_minimum]|max|round(0, 'ceil')|int %} - {action_respond_info("BLOBIFIER: Purging %dmm of filament" % (purge_len))} - - # ====================================================================================== - # ==================== PURGING SEQUENCE ================================================ - # ====================================================================================== - - # Set to absolute positioning. - G90 - - # Check for purge length and purge if necessary. - {% if purge_len|float > 0 %} - - # ==================================================================================== - # ==================== POSITIONING =================================================== - # ==================================================================================== - - # Retract the tray so it is not in the way - BLOBIFIER_SERVO POS=in - - # Move to the assembly, first a bit more to the right (brush_start) to avoid a - # potential filametrix pin if it's not already on the same Y coordinate. - {% if printer.toolhead.position.y != position_y %} - G1 X{[brush_start - 20, 30]|max} Y{position_y} F{travel_spd_xy} - {% endif %} - - # ==================================================================================== - # ==================== BUCKET SHAKE ================================================== - # ==================================================================================== - - {% if enable_shaker and (safe.shake or ignore_safe) %} - {% if (bl_count.current_blobs + 1) >= bl_count.next_shake %} - BLOBIFIER_SHAKE_BUCKET SHAKES={bucket_shakes} - _BLOBIFIER_CALCULATE_NEXT_SHAKE - {% endif %} - {% endif %} - - # ==================================================================================== - # ==================== POSITIONING ON TRAY =========================================== - # ==================================================================================== - {% if safe.tray or ignore_safe %} - G1 Z{tray_top + purge_start} F{travel_spd_z} - {% endif %} - - # Move over to the tray after z change (For cases when the tool is lower than the tray) - G1 X{purge_x} F{travel_spd_xy} - - # Extend the tray - BLOBIFIER_SERVO POS=out - - # ==================================================================================== - # ==================== HEAT HOTEND =================================================== - # ==================================================================================== - - {% if printer.extruder.temperature < purge_temp_min %} - {% if printer.extruder.target < purge_temp_min %} - M109 S{purge_temp_min} - {% else %} - TEMPERATURE_WAIT SENSOR=extruder MINIMUM={purge_temp_min} - {% endif %} - {% endif %} - - # ==================================================================================== - # ==================== START ITERATING =============================================== - # ==================================================================================== - - # Calculate total number of iterations based on the purge length and the max_iteration - # length. - {% set blobs = (purge_len / purge_length_maximum)|round(0, 'ceil')|int %} - {% set purge_per_blob = purge_len|float / blobs %} - {% set retracts_per_blob = (purge_per_blob / 40)|round(0, 'ceil')|int %} - {% set purge_per_retract = (purge_per_blob / retracts_per_blob)|int %} - {% set pulses_per_retract = (purge_per_blob / retracts_per_blob / 5)|round(0, 'ceil')|int %} - {% set pulses_per_blob = (purge_per_blob / 5)|round(0, 'ceil')|int %} - {% set purge_per_pulse = purge_per_blob / pulses_per_blob %} - {% set pulse_time_constant = purge_per_pulse * 0.95 / purge_spd / (purge_per_pulse * 0.95 / purge_spd + purge_per_pulse * 0.05 / 50) %} - {% set pulse_duration = purge_per_pulse / purge_spd %} - - # Repeat the process until purge_len is reached - {% for blob in range(blobs) %} - RESPOND MSG={"'BLOBIFIER: Blob %d of %d (%.1fmm)'" % (blob + 1, blobs, purge_per_blob)} - - {% if safe.tray or ignore_safe %} - G1 Z{tray_top + purge_start} F{travel_spd_z} - {% endif %} - - # relative positioning - G91 - # relative extrusion - M83 - - # Un-retract other than the first purge, to re-prime nozzle - {% if not loop.first %} - M400 - __BLOBIFIER_EXTRUDER_MOVE E={retract_between_blobs} EXTRUDER_SPEED={sequence_vars.retract_speed * 60} - {% endif %} - - # Purge filament in a pulsating motion to purge the filament quicker and better - {% for pulse in range(pulses_per_blob) %} - # Calculations to determine z-speed - {% set purged_this_blob = pulse * purge_per_pulse %} - {% set z_last_pos = purge_start + ((purged_this_blob)/purge_length_maximum)**z_raise_exp * z_raise %} - {% set z_pos = purge_start + ((purged_this_blob + purge_per_pulse)/purge_length_maximum)**z_raise_exp * z_raise %} - {% set z_up = z_pos - z_last_pos %} - {% set speed = z_up / pulse_duration %} - - # Purge quickly - __BLOBIFIER_EXTRUDER_MOVE EXTRUDER_SPEED={speed} E={purge_per_pulse * 0.95} NEW_Z={z_up * pulse_time_constant} - # Purge a tiny bit slowly - __BLOBIFIER_EXTRUDER_MOVE EXTRUDER_SPEED={speed} E={purge_per_pulse * 0.05} NEW_Z={z_up * (1 - pulse_time_constant)} - - # retract and unretract filament every now and then for thorough cleaning - {% if pulse % pulses_per_retract == 0 and pulse > 0 %} - __BLOBIFIER_EXTRUDER_MOVE E=-2 EXTRUDER_SPEED=1800 - __BLOBIFIER_EXTRUDER_MOVE E=2 EXTRUDER_SPEED=800 - {% endif %} - - {% endfor %} - - # ================================================================================== - # ==================== DEPOSIT BLOB ================================================ - # ================================================================================== - - # Perform retraction - {% if loop.last %} - # This is the last blob - retract to match what Happy Hare is expecting - __BLOBIFIER_EXTRUDER_MOVE E=-{park_vars.retracted_length} EXTRUDER_SPEED={sequence_vars.retract_speed * 60} - {% else %} - # This is an in between blob - retract by the in between blobs retraction value - __BLOBIFIER_EXTRUDER_MOVE E=-{retract_between_blobs} EXTRUDER_SPEED={sequence_vars.retract_speed * 60} - {% endif %} - - - {% if safe.tray or ignore_safe %} - # Set blob depositing fan speed - {% if part_cooling_fan_blob_deposit >= 0 %} - M106 S{(part_cooling_fan_blob_deposit * 255)|int} - {% endif %} - - # Raise z a bit to relieve pressure on the blob preventing it to go sideways - G1 Z{eject_hop} F{travel_spd_z} - # Retract the tray - BLOBIFIER_SERVO POS=in - # Move the toolhead down to purge_start height lowering the blob below the tray - G90 # absolute positioning - G1 Z{tray_top} F{travel_spd_z} - - # Dwell to release pressure before cutting the blob off - M400 - G4 P{pressure_release_time} - - # Extend the tray to 'cut off' the blob and prepare for the next blob - BLOBIFIER_SERVO POS=out - BLOBIFIER_SERVO POS=in - BLOBIFIER_SERVO POS=out - # Keep track of the # of blobs - _BLOBIFIER_COUNT - - # Set purging fan speed - {% if part_cooling_fan < 0 %} - # If part cooling fan is unchanged and equal to print speed fan - M106 S{(backup_fan_speed * 255)|int} - {% elif part_cooling_fan >= 0 %} - # If specific part cooling fan speed is set - M106 S{(part_cooling_fan * 255)|int} - {% endif %} - - {% endif %} - {% endfor %} - {% endif %} - - # Adjust tension and centre sensor to ensure we don't accidentally trigger FlowGuard runout when print resumes - {% if printer.mmu.sync_feedback_enabled %} - MMU_SYNC_FEEDBACK ADJUST_TENSION=1 - {% endif %} - - {% if safe.tray or ignore_safe %} - G1 Z{tray_top + 1} F{travel_spd_z} - {% endif %} - {% if safe.brush or ignore_safe %} - BLOBIFIER_CLEAN - {% else %} - G1 X{brush_start} F{travel_spd_xy} - {% endif %} - - # ====================================================================================== - # ==================== RESTORE STATE =================================================== - # ====================================================================================== - - G90 # absolute positioning - G1 Z{restore_z} F{travel_spd_z} - - # Reset part cooling fan unconditionally - M106 S{(backup_fan_speed * 255)|int} - - # Restore feedrate - M220 S{(backup_feedrate * 100)|int} - - # Restore Pressure Advance - RESPOND MSG={"'BLOBIFIER: Restoring PA to %.4f'" % (backup_pa)} - SET_PRESSURE_ADVANCE ADVANCE={backup_pa} - {% endif %} - - # Retract the tray - BLOBIFIER_SERVO POS=in - - RESTORE_GCODE_STATE NAME=BLOBIFIER_state - -########################################################################################## -# Extrusion helper that allows for check of "runout/clog" indicator -# -[gcode_macro __BLOBIFIER_EXTRUDER_MOVE] -description: Extruder move (optional Z) with clog/runout guard -# Parameters: -# EXTRUDER_SPEED in mm/min -# E in mm -# NEW_Z in mm -gcode: - {% set extruder_speed = params.EXTRUDER_SPEED|float %} - {% set E = params.E|float %} - {% set new_z = params.NEW_Z|default(None) %} - - {% set clog_runout_detected = printer.mmu.clog_runout_detected|default(false)|lower == 'true' %} - - {% if not clog_runout_detected %} - {% if new_z is not none %} - G1 Z{new_z} E{E} F{extruder_speed} - {% else %} - G1 E{E} F{extruder_speed} - {% endif %} - {% endif %} - -########################################################################################## -# Wipes the nozzle on the brass brush -# -[gcode_macro BLOBIFIER_CLEAN] -gcode: - {% set bl = printer['gcode_macro BLOBIFIER'] %} - {% set pos_max = printer.toolhead.axis_maximum %} - {% set position_y = pos_max.y - bl.skew_correction - bl.brush_y_offset %} - {% set original_accel = printer.toolhead.max_accel %} - {% set original_minimum_cruise_ratio = printer.toolhead.minimum_cruise_ratio %} - {% set pos = printer.gcode_move.gcode_position %} - {% set min_z = (bl.brush_top or 0) + bl.clearance_z %} - - SAVE_GCODE_STATE NAME=BLOBIFIER_CLEAN_state - - G90 - - {% if bl.brush_accel > 0 %} - SET_VELOCITY_LIMIT ACCEL={bl.brush_accel} MINIMUM_CRUISE_RATIO=0.1 - {% endif %} - - # Ensure we have our clearance...we don't want any bed scraping here! - {% if pos.z < min_z %} - G1 Z{min_z} F{bl.travel_spd_z} - {% endif %} - G1 X{bl.brush_start} F{bl.travel_spd_xy} - G1 Y{position_y} - - {% if bl.brush_top is not none %} - # Ensure clearance above the brush - G1 Z{bl.brush_top + bl.clearance_z} F{bl.travel_spd_z} - - # Move nozzle down into brush. - G1 Z{bl.brush_top} F{bl.travel_spd_z} - {% endif %} - - SET_VELOCITY_LIMIT ACCEL={original_accel} MINIMUM_CRUISE_RATIO={original_minimum_cruise_ratio} - - # Perform wipe. Wipe direction based off bucket_pos for cool random scrubby routine. - {% for wipes in range(1, (bl.wipe_qty + 1)) %} - G1 X{bl.brush_start + bl.brush_width} F{bl.wipe_spd_xy} - G1 X{bl.brush_start} F{bl.wipe_spd_xy} - {% endfor %} - - # Move away from the brush, but not onto the tray or in front of the filametrix cutter pin - G1 X{[bl.brush_start - 20, 30]|max} F{bl.travel_spd_xy} - - RESTORE_GCODE_STATE NAME=BLOBIFIER_CLEAN_state - - - -########################################################################################## -# Park the nozzle on the tray to prevent oozing during filament swaps. Place this -# extension in the post_form_tip extension in mmu_macro_vars.cfg: -# variable_user_post_form_tip_extension: "BLOBIFIER_PARK" -# -[gcode_macro BLOBIFIER_PARK] -variable_restore_z: 0 -gcode: - {% set bl = printer['gcode_macro BLOBIFIER'] %} - {% set pos = printer.gcode_move.gcode_position %} - {% set safe = printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'] %} - {% set pos_max = printer.toolhead.axis_maximum %} - {% set position_y = pos_max.y - bl.skew_correction - bl.y_offset %} - - SET_GCODE_VARIABLE MACRO=BLOBIFIER_PARK VARIABLE=restore_z VALUE={pos.z} - - SAVE_GCODE_STATE NAME=blobifier_park_state - - {% if "xyz" in printer.toolhead.homed_axes and printer.quad_gantry_level and printer.quad_gantry_level.applied %} - G90 - - # Retract the tray - BLOBIFIER_SERVO POS=in - - G1 X{[bl.brush_start - 20, 30]|max} Y{position_y} F{bl.travel_spd_xy} - {% if safe.tray or ignore_safe %} - G1 Z{bl.tray_top} F{bl.travel_spd_z} - {% endif %} - G1 X{bl.purge_x} F{bl.travel_spd_xy} - - # Extend the tray - BLOBIFIER_SERVO POS=out - - {% else %} - RESPOND MSG="Please home (and QGL) before parking" - {% endif %} - - RESTORE_GCODE_STATE NAME=blobifier_park_state - -########################################################################################## -# Retract or extend the tray -# POS=[in|out] Retractor extend the tray -# -[gcode_macro BLOBIFIER_SERVO] -gcode: - {% set bl = printer['gcode_macro BLOBIFIER'] %} - {% set pos = params.POS %} - {% if pos == "in" %} - SET_SERVO SERVO=blobifier ANGLE={bl.tray_angle_in} - G4 P{bl.dwell_time} - {% elif pos == "out" %} - SET_SERVO SERVO=blobifier ANGLE={bl.tray_angle_out} - G4 P{bl.dwell_time} - {% else %} - {action_respond_info("BLOBIFIER: provide POS=[in|out]")} - {% endif %} - SET_SERVO SERVO=blobifier WIDTH=0 - -########################################################################################## -# Define exclude objects for those who haven't already -# -[exclude_object] - -########################################################################################## -# Overwrite the existing EXCLUDE_OBJECT_DEFINE to also check for safe descend. -# -[gcode_macro EXCLUDE_OBJECT_DEFINE] -rename_existing: _EXCLUDE_OBJECT_DEFINE -gcode: - # only reset on the first object at the beginning of a print - {% if printer.exclude_object.objects|length < 1 %} - _BLOBIFIER_RESET_SAFE_DESCEND - {% endif %} - _EXCLUDE_OBJECT_DEFINE {rawparams} - _BLOBIFIER_SAFE_DESCEND - UPDATE_DELAYED_GCODE ID=BLOBIFIER_SHOW_SAFE_DESCEND DURATION=1 - -[delayed_gcode BLOBIFIER_SHOW_SAFE_DESCEND] -gcode: - {% set safe = printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'] %} - {action_respond_info( - "BLOBIFIER: Safe descend possible:\n - tray: %s\n - brush: %s\n - shake: %s" % - ( - "yes" if safe.tray else "no", - "yes" if safe.brush else "no", - "yes" if safe.shake else "no" - ) - )} - -########################################################################################## -# Use the EXCLUDE_OBJECT_START gcode macro to record the current height -# -[gcode_macro EXCLUDE_OBJECT_START] -rename_existing: _EXCLUDE_OBJECT_START -gcode: - _EXCLUDE_OBJECT_START {rawparams} - {% if printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'].first_layer %} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=first_layer VALUE=False - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_height VALUE={printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'].print_layer_height} - {% else %} - {% set pos = printer.gcode_move.gcode_position %} - {% set last_height = printer['gcode_macro _BLOBIFIER_SAFE_DESCEND'].print_previous_height|float %} - {% if pos.z > last_height %} - {% set last_layer = (pos.z - last_height)|round(2) %} - {% set print_height = (pos.z + last_layer)|round(2) %} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_previous_height VALUE={pos.z} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_height VALUE={print_height} - {% endif %} - {% endif %} - -########################################################################################## -# Reset the safe descend variables. -# -[gcode_macro _BLOBIFIER_RESET_SAFE_DESCEND] -gcode: - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=tray VALUE=True - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=brush VALUE=True - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=shake VALUE=True - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=first_layer VALUE=True - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_height VALUE=0 - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=print_previous_height VALUE=0 - -########################################################################################## -# Determine if it is safe to drop the toolhead (e.g. not hit a print) -# -[gcode_macro _BLOBIFIER_SAFE_DESCEND] -variable_tray: True # Assume it is safe -variable_brush: True -variable_shake: True -variable_first_layer: True -variable_print_height: 0 -variable_print_previous_height: 0 -variable_print_layer_height: 0.3 -gcode: - {% set bl = printer['gcode_macro BLOBIFIER'] %} - {% set pos_max = printer.toolhead.axis_maximum %} - {% set position_y = pos_max.y - bl.skew_correction - bl.y_offset %} - {% set brush_position_y = pos_max.y - bl.skew_correction - bl.brush_y_offset %} - {% set tray = [bl.purge_x + bl.toolhead_x, position_y - bl.toolhead_y] %} - {% set brush = [bl.brush_start + bl.brush_width + bl.toolhead_x, brush_position_y - bl.toolhead_y] %} - {% set shake = [bl.purge_x + bl.toolhead_x, position_y - bl.toolhead_y - 4] %} - {% set objects = printer.exclude_object.objects | map(attribute='polygon') %} - - {% for polygon in objects %} - {% for point in polygon %} - {% if point[0] < tray[0] and point[1] > tray[1] %} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=tray VALUE=False - {% endif %} - {% if bl.brush_top is not none and point[0] < brush[0] and point[1] > brush[1] %} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=brush VALUE=False - {% endif %} - {% if point[0] < shake[0] and point[1] > shake[1] %} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_SAFE_DESCEND VARIABLE=shake VALUE=False - {% endif %} - {% endfor %} - {% endfor %} - -########################################################################################## -# Increment the blob count with 1 and check if the bucket is full. Pause -# the printer if it is. -# -[gcode_macro _BLOBIFIER_COUNT] -# Don't change these variables -variable_current_blobs: 0 -variable_last_shake: 0 -variable_next_shake: 0 -gcode: - {% set bl = printer['gcode_macro BLOBIFIER'] %} - {% set count = printer['gcode_macro _BLOBIFIER_COUNT'] %} - {% if current_blobs >= bl.max_blobs %} - {action_respond_info("BLOBIFIER: Empty purge bucket!")} - M117 Empty purge bucket! - MMU_PAUSE MSG="Empty purge bucket!" - {% else %} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=current_blobs VALUE={current_blobs + 1} - _BLOBIFIER_SAVE_STATE - {action_respond_info( - "BLOBIFIER: Blobs in bucket: %s/%s. Next shake @ %s" - % (current_blobs + 1, bl.max_blobs, next_shake) - )} - {% endif %} - -########################################################################################## -# Reset the blob count to 0 -# -[gcode_macro _BLOBIFIER_COUNT_RESET] -gcode: - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=current_blobs VALUE=0 - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=last_shake VALUE=0 - _BLOBIFIER_SAVE_STATE - - _BLOBIFIER_CALCULATE_NEXT_SHAKE - -########################################################################################## -# Shake the blob bucket to disperse the blobs -# -[gcode_macro BLOBIFIER_SHAKE_BUCKET] -gcode: - {% set bl = printer['gcode_macro BLOBIFIER'] %} - {% set count = printer['gcode_macro _BLOBIFIER_COUNT'] %} - {% set original_accel = printer.toolhead.max_accel %} - {% set original_minimum_cruise_ratio = printer.toolhead.minimum_cruise_ratio %} - {% set position_x = bl.shaker_pos_x|default(0)|float + bl.skew_correction %} - - {% if "xyz" not in printer.toolhead.homed_axes %} - {action_raise_error("BLOBIFIER: Not homed. Home xyz first")} - {% endif %} - - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=last_shake VALUE={count.current_blobs} - _BLOBIFIER_SAVE_STATE - SAVE_GCODE_STATE NAME=shake_bucket - - M400 - M117 (^_^) - - G90 - {% set shakes = params.SHAKES|default(10)|int %} - {% set pos_max = printer.toolhead.axis_maximum %} - {% set position_y = pos_max.y - bl.skew_correction - bl.y_offset %} - - # move to save y if not already there - {% if printer.toolhead.position.y != position_y %} - G1 X{bl.brush_start} Y{position_y} F{bl.travel_spd_xy} - {% endif %} - - # Retract the tray - BLOBIFIER_SERVO POS=in - - # move up a bit to prevent oozing on base - G1 Z{bl.shaker_arm_z} F{bl.travel_spd_z} - # slide into the slot - G1 X{position_x} F{bl.travel_spd_xy} - - M400 - M117 (+(+_+)+) - - SET_VELOCITY_LIMIT ACCEL={bl.shake_accel} MINIMUM_CRUISE_RATIO=0.1 - - # Shake away! - {% for shake in range(1, shakes) %} - G1 Y{position_y - 4} - G1 Y{position_y} - {% endfor %} - - SET_VELOCITY_LIMIT ACCEL={original_accel} MINIMUM_CRUISE_RATIO={original_minimum_cruise_ratio} - # move out of slot - G1 X{bl.purge_x} - - M400 - M117 (X_x) - - RESTORE_GCODE_STATE NAME=shake_bucket - -########################################################################################## -# Calculate when the bucket should be shaken. -# -[gcode_macro _BLOBIFIER_CALCULATE_NEXT_SHAKE] -gcode: - {% set bl = printer['gcode_macro BLOBIFIER'] %} - {% set count = printer['gcode_macro _BLOBIFIER_COUNT'] %} - - {% set remaining_blobs = bl.max_blobs - count.last_shake %} - {% set next_shake = (1 - bl.bucket_shake_frequency) * remaining_blobs + count.last_shake %} - _BLOBIFIER_SAVE_STATE - _BLOBIFIER_SET_NEXT_SHAKE VALUE={next_shake|int} - -########################################################################################## -# Set when the bucket should be shaken next -# VALUE=[int] At what amount of blobs should it be shaken -# -[gcode_macro _BLOBIFIER_SET_NEXT_SHAKE] -gcode: - {% if params.VALUE %} - {% set next_shake = params.VALUE %} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=next_shake VALUE={next_shake} - _BLOBIFIER_SAVE_STATE - {% else %} - {action_respond_info("BLOBIFIER: Provide parameter VALUE=")} - {% endif %} - -########################################################################################## -# Some sanity checks -# -[delayed_gcode BLOBIFIER_INIT] -initial_duration: 5.0 -gcode: - _BLOBIFIER_INIT - # Extend and retract the tray to test - BLOBIFIER_SERVO POS=out - BLOBIFIER_SERVO POS=in - -[gcode_macro _BLOBIFIER_INIT] -gcode: - {% set bl = printer['gcode_macro BLOBIFIER'] %} - {% set axis_min = printer.toolhead.axis_minimum %} - {% set axis_max = printer.toolhead.axis_maximum %} - - # Valid part cooling fan setting - {% if bl.part_cooling_fan != -1 and (bl.part_cooling_fan < 0 or bl.part_cooling_fan > 1) %} - {action_emergency_stop("BLOBIFIER: Value %f is invalid for variable part_cooling_fan. Either -1 or a value from 0 .. 1 is valid." % (bl.part_cooling_fan))} - {% endif %} - - # Valid bucket shake frequency - {% if bl.bucket_shake_frequency < 0 or bl.bucket_shake_frequency > 1 %} - {action_emergency_stop("BLOBIFIER: Value %f is invalid for variable bucket_shake_frequency. Change it to a value between 0 .. 1" % (bl.bucket_shake_frequency))} - {% endif %} - - # Check if position is on 'next' - {% if printer.mmu %} - {% if printer['gcode_macro _MMU_SEQUENCE_VARS'].restore_xy_pos != 'next' %} - {action_respond_info("BLOBIFIER: If not using a wipe tower, consider setting restore_xy_pos: 'next' in mmu_macro_vars.cfg")} - {% endif %} - {% endif %} - - # Check the z_raise variable for normal values - {% if bl.z_raise < 3 %} - {action_respond_info("BLOBIFIER: variable_z_raise: %f is very low. This is the value z raises in total on a single blob. Make sure the value is correct before continuing." % (bl.z_raise))} - {% endif %} - - # Z raise exponent - {% if bl.z_raise_exp > 1 or bl.z_raise_exp < 0.5 %} - {action_respond_info("BLOBIFIER: variable_z_raise_exp has value: %f. This value is out of spec (0.5 ... 1.0)." % (bl.z_raise_exp))} - {% endif %} - - # cap user defined accels at printer max_accel if greater - {% if bl.shake_accel > printer.configfile.config.printer.max_accel|int %} - {action_respond_info("BLOBIFIER: variable_shake_accel has value: %d which is higher than your printer limit of %d. Reduce this if your printer skips steps." % (bl.shake_accel,printer.configfile.config.printer.max_accel|int))} - {% endif %} - {% if bl.brush_accel > printer.configfile.config.printer.max_accel|int %} - {action_respond_info("BLOBIFIER: variable_brush_accel has value: %d which is higher than your printer limit of %d. Reduce this if your printer skips steps." % (bl.brush_accel,printer.configfile.config.printer.max_accel|int))} - {% endif %} - - # Check brush_top variable - _BLOBIFIER_VALIDATE_FLOAT_VARIABLE NAME=brush_top MIN={axis_min.z} MAX={axis_max.z} ALLOW_NONE=1 - -[gcode_macro _BLOBIFIER_VALIDATE_FLOAT_VARIABLE] -gcode: - {% set name = params.NAME %} - {% set min_value = params.MIN|default(None)|float(None) %} - {% set max_value = params.MAX|default(None)|float(None) %} - {% set allow_none = params.ALLOW_NONE|default(0)|int %} - {% set bl = printer['gcode_macro BLOBIFIER'] %} - - {% set value = bl[name]|float(None) %} - - # Save the normalized value back to the variable - SET_GCODE_VARIABLE MACRO=BLOBIFIER VARIABLE={name} VALUE={value} - - {% if not allow_none and value is none %} - {action_emergency_stop("BLOBIFIER: %s is not set. Please provide a value." % (name))} - {% endif %} - - # If number is less than min, then we should just emergency stop now - {% if value is number and min_value is not none and value < min_value %} - {action_emergency_stop("BLOBIFIER: %s has value: %.2f which is below min of %.2f. Adjust %s!" % (name, value, min_value, name))} - - # If number is greater than max, then we should just emergency stop now - {% elif value is number and max_value is not none and value > max_value %} - {action_emergency_stop("BLOBIFIER: %s has value: %.2f which is above max of %.2f. Adjust %s!" % (name, value, max_value, name))} - {% endif %} - -[delayed_gcode BLOBIFIER_LOAD_STATE] -initial_duration: 2.0 # Give it some time to boot up -gcode: - {% set sv = printer.save_variables.variables.blobifier %} - - {% if sv %} - # Restore state - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=last_shake VALUE={sv.last_shake} - SET_GCODE_VARIABLE MACRO=_BLOBIFIER_COUNT VARIABLE=current_blobs VALUE={sv.current_blobs} - {% endif %} - _BLOBIFIER_CALCULATE_NEXT_SHAKE - -[gcode_macro _BLOBIFIER_SAVE_STATE] -gcode: - {% set count = printer['gcode_macro _BLOBIFIER_COUNT'] %} - {% set sv = {'current_blobs': count.current_blobs, 'last_shake': count.last_shake} %} - SAVE_VARIABLE VARIABLE=blobifier VALUE="{sv}" diff --git a/klippy/extras/Happy-Hare/config/addons/blobifier_hw.cfg b/klippy/extras/Happy-Hare/config/addons/blobifier_hw.cfg deleted file mode 100644 index 5f76ae8c5c95..000000000000 --- a/klippy/extras/Happy-Hare/config/addons/blobifier_hw.cfg +++ /dev/null @@ -1,28 +0,0 @@ - -########################################################################################## -# The servo hardware configuration. Change the values to your needs. -# -[mmu_servo blobifier] -# Pin for the servo. -pin: PG14 -# Adjust this value until a 'BLOBIFIER_SERVO POS=out' extends the tray fully without a -# buzzing sound -minimum_pulse_width: 0.00053 -# Adjust this value until a 'BLOBIFIER_SERVO POS=in' retracts the tray fully without a -# buzzing sound -maximum_pulse_width: 0.0023 -# Leave this value at 180 -maximum_servo_angle: 180 - - -########################################################################################## -# The bucket hardware configuration. Change the pin to whatever pin you've connected the -# switch to. -# -[gcode_button bucket] -pin: ^PG15 # The pullup ( ^ ) is important here. -press_gcode: - M117 bucket installed -release_gcode: - M117 bucket removed - _BLOBIFIER_COUNT_RESET diff --git a/klippy/extras/Happy-Hare/config/addons/mmu_eject_buttons.cfg b/klippy/extras/Happy-Hare/config/addons/mmu_eject_buttons.cfg deleted file mode 100644 index afc39df6942b..000000000000 --- a/klippy/extras/Happy-Hare/config/addons/mmu_eject_buttons.cfg +++ /dev/null @@ -1,35 +0,0 @@ -# Include servo hardware definition separately to allow for automatic upgrade -[include mmu_eject_buttons_hw.cfg] - -########################################################################### -# Optional hardware MMU eject buttons (e.g. QuattroBox) -# -# This is the supplementary macro to support dedicated per-gate eject -# buttons for easy unloading. It is complimentary to the built-in auto -# preload of filament -# -# To configure: -# 1. Add this to your printer.cfg: -# -# [include mmu/addons/mmu_eject_buttons.cfg] -# - -########################################################################### -# Macro to simply call MMU_EJECT for the specified gate -# -# This logic is separated from actual button h/w setup to facilitate upgrades -# and to allow addition of logic (perhaps validation or warning logic) -# -[gcode_macro _MMU_EJECT_BUTTON] -description: Wrapper around ejecting filament via dedicated hardware buttons -gcode: - {% set gate = params.GATE|default(-1)|int %} - {% set mmu = printer['mmu'] %} - {% set current_gate = mmu.gate %} - {% set is_printing = printer.mmu.print_state in ["started", "printing"] %} - - {% if not is_printing %} - MMU_EJECT GATE={gate} - {% else %} - MMU_LOG MSG="Eject operation not possible when actively printing. Pause first" ERROR=1 - {% endif %} diff --git a/klippy/extras/Happy-Hare/config/addons/mmu_eject_buttons_hw.cfg b/klippy/extras/Happy-Hare/config/addons/mmu_eject_buttons_hw.cfg deleted file mode 100644 index 693ee579b44d..000000000000 --- a/klippy/extras/Happy-Hare/config/addons/mmu_eject_buttons_hw.cfg +++ /dev/null @@ -1,21 +0,0 @@ - -########################################################################################## -# The eject button hardware configuration. Change the values to your needs and number -# of gates -# - -[gcode_button mmu_eject_button_0] -pin: mmu:EJECT_BUTTON_0 -press_gcode: _MMU_EJECT_BUTTON GATE=0 - -[gcode_button mmu_eject_button_1] -pin: mmu:EJECT_BUTTON_1 -press_gcode: _MMU_EJECT_BUTTON GATE=1 - -[gcode_button mmu_eject_button_2] -pin: mmu:EJECT_BUTTON_2 -press_gcode: _MMU_EJECT_BUTTON GATE=2 - -[gcode_button mmu_eject_button_3] -pin: mmu:EJECT_BUTTON_3 -press_gcode: _MMU_EJECT_BUTTON GATE=3 diff --git a/klippy/extras/Happy-Hare/config/addons/mmu_erec_cutter.cfg b/klippy/extras/Happy-Hare/config/addons/mmu_erec_cutter.cfg deleted file mode 100644 index 12e1d53f0a47..000000000000 --- a/klippy/extras/Happy-Hare/config/addons/mmu_erec_cutter.cfg +++ /dev/null @@ -1,91 +0,0 @@ -# Include servo hardware definition separately to allow for automatic upgrade -[include mmu_erec_cutter_hw.cfg] - -########################################################################### -# Optional EREC Filament Cutter Support -# -# https://github.com/kevinakasam/ERCF_Filament_Cutter -# -# This is the supplementary macro to support filament cutting at the MMU -# on a ERCF design. -# -# To configure: -# 1. Add this to your printer.cfg: -# -# [include mmu/addons/mmu_erec_cutter.cfg] -# -# 2. In mmu_macro_vars.cfg, change this line: -# -# variable_user_post_unload_extension : "EREC_CUTTER_ACTION" -# -# 3. Tune the servo configuration and macro "variables" below -# - -# EREC CUTTER CONFIGURATION ----------------------------------------------- -# (addons/mmu_erec_cutter.cfg) -# -[gcode_macro _EREC_VARS] -description: Empty macro to store the variables -gcode: # Leave empty - -# These variables control the servo movement -variable_servo_closed_angle : 70 ; Servo angle for closed position with bowden aligned MMU -variable_servo_open_angle : 10 ; Servo angle to open up the cutter and move bowden away from MMU -variable_servo_duration : 1.5 ; Time (s) of PWM pulse train to activate servo -variable_servo_idle_time : 1.8 ; Time (s) to let the servo to reach it's position - -# Controls for feed and cut lengths -variable_feed_length : 48 ; Distance in mm from gate parking position to blade (ERCFv1.1: 58, v2/other: 48) -variable_cut_length : 10 ; Amount in mm of filament to cut -variable_cut_attempts : 1 ; Number of times the cutter tries to cut the filament - - -########################################################################### -# Macro to perform the cutting step. Designed to be included to the -# _MMU_POST_UNLOAD step -# -[gcode_macro EREC_CUTTER_ACTION] -description: Cut off the filament tip at the MMU after the unload sequence is complete -gcode: - {% set vars = printer["gcode_macro _EREC_VARS"] %} - - MMU_LOG MSG="Cutting filament tip..." - - _CUTTER_OPEN - _MMU_STEP_MOVE MOVE={vars.feed_length + vars.cut_length} - {% for i in range(vars.cut_attempts - 1) %} - _CUTTER_CLOSE - _CUTTER_OPEN - {% endfor %} - _MMU_STEP_MOVE MOVE=-1 - _CUTTER_CLOSE - _MMU_EVENT EVENT="filament_cut" # Count as one cut for consumption counter - - _MMU_STEP_SET_FILAMENT STATE=2 # FILAMENT_POS_START_BOWDEN - _MMU_STEP_UNLOAD_GATE # Repeat gate parking move - _MMU_M400 # Wait on both move queues - -[gcode_macro _CUTTER_ANGLE] -description: Helper macro to set cutter servo angle -gcode: - {% set angle = params.ANGLE|default(0)|int %} - SET_SERVO SERVO=cut_servo ANGLE={angle} - -[gcode_macro _CUTTER_CLOSE] -description: Helper macro to set cutting servo the closed position -gcode: - {% set vars = printer["gcode_macro _EREC_VARS"] %} - SET_SERVO SERVO=cut_servo ANGLE={vars.servo_closed_angle} DURATION={vars.servo_duration} - G4 P{vars.servo_idle_time * 1000} - RESPOND MSG="EREC Cutter closed" - M400 - -[gcode_macro _CUTTER_OPEN] -description: Helper macro to set cutting servo the open position -gcode: - {% set vars = printer["gcode_macro _EREC_VARS"] %} - SET_SERVO SERVO=cut_servo ANGLE={vars.servo_open_angle} DURATION={vars.servo_duration} - G4 P{vars.servo_idle_time * 1000} - RESPOND MSG="EREC Cutter open" - M400 - diff --git a/klippy/extras/Happy-Hare/config/addons/mmu_erec_cutter_hw.cfg b/klippy/extras/Happy-Hare/config/addons/mmu_erec_cutter_hw.cfg deleted file mode 100644 index 4fec0c18d017..000000000000 --- a/klippy/extras/Happy-Hare/config/addons/mmu_erec_cutter_hw.cfg +++ /dev/null @@ -1,10 +0,0 @@ - -########################################################################################## -# The servo hardware configuration. Change the values to your needs. -# -[mmu_servo cut_servo] -pin: mmu:PA7 # Extra Pin on the ERCF easy Board -maximum_servo_angle: 180 # Set this to 60 for a 60° Servo -minimum_pulse_width: 0.0005 # Adapt these for your servo -maximum_pulse_width: 0.0025 # Adapt these for your servo - diff --git a/klippy/extras/Happy-Hare/config/base/mmu.cfg b/klippy/extras/Happy-Hare/config/base/mmu.cfg deleted file mode 100644 index 0a0789eda3c9..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu.cfg +++ /dev/null @@ -1,123 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Happy Hare MMU hardware pin config -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# This contains aliases for pins for MCU type {brd_type} -# -[mcu mmu] -serial: {serial} # Change to `canbus_uuid: 1234567890` for CANbus setups - -# If using proportional sync-feedback sensor (like FPS), define mcu connection here -#[mcu fps] -#canbus_uuid: -#canbus_interface: can0 - - -# PIN ALIASES FOR MMU MCU BOARD ---------------------------------------------------------------------------------------- -# ██████╗ ██╗███╗ ██╗ █████╗ ██╗ ██╗ █████╗ ███████╗ -# ██╔══██╗██║████╗ ██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝ -# ██████╔╝██║██╔██╗ ██║ ███████║██║ ██║███████║███████╗ -# ██╔═══╝ ██║██║╚██╗██║ ██╔══██║██║ ██║██╔══██║╚════██║ -# ██║ ██║██║ ╚████║ ██║ ██║███████╗██║██║ ██║███████║ -# ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝╚══════╝ -# Section to create alias for pins used by MMU for easier integration into Klippain and RatOS. The names match those -# referenced in the mmu_hardware.cfg file. If you get into difficulty you can also comment out this aliases definition -# completely and configure the pin names directly into mmu_hardware.cfg. However, use of aliases is encouraged. - -# Note: that aliases are not created for TOOLHEAD_SENSOR, EXTRUDER_SENSOR or SYNC_FEEDBACK_SENSORS because those are -# most likely on the printer's main mcu. These should be set directly in mmu_hardware.cfg -# -[board_pins mmu] -mcu: mmu # Assumes using an external / extra mcu dedicated to MMU -aliases: - MMU_GEAR_UART={gear_uart_pin}, - MMU_GEAR_STEP={gear_step_pin}, - MMU_GEAR_DIR={gear_dir_pin}, - MMU_GEAR_ENABLE={gear_enable_pin}, - MMU_GEAR_DIAG={gear_diag_pin}, - - MMU_GEAR_UART_1={gear_1_uart_pin}, - MMU_GEAR_STEP_1={gear_1_step_pin}, - MMU_GEAR_DIR_1={gear_1_dir_pin}, - MMU_GEAR_ENABLE_1={gear_1_enable_pin}, - MMU_GEAR_DIAG_1={gear_1_diag_pin}, - - MMU_GEAR_UART_2={gear_2_uart_pin}, - MMU_GEAR_STEP_2={gear_2_step_pin}, - MMU_GEAR_DIR_2={gear_2_dir_pin}, - MMU_GEAR_ENABLE_2={gear_2_enable_pin}, - MMU_GEAR_DIAG_2={gear_2_diag_pin}, - - MMU_GEAR_UART_3={gear_3_uart_pin}, - MMU_GEAR_STEP_3={gear_3_step_pin}, - MMU_GEAR_DIR_3={gear_3_dir_pin}, - MMU_GEAR_ENABLE_3={gear_3_enable_pin}, - MMU_GEAR_DIAG_3={gear_3_diag_pin}, - - MMU_SEL_UART={selector_uart_pin}, - MMU_SEL_STEP={selector_step_pin}, - MMU_SEL_DIR={selector_dir_pin}, - MMU_SEL_ENABLE={selector_enable_pin}, - MMU_SEL_DIAG={selector_diag_pin}, - MMU_SEL_ENDSTOP={selector_endstop_pin}, - MMU_SEL_SERVO={selector_servo_pin}, - - MMU_ENCODER={encoder_pin}, - MMU_GATE_SENSOR={gate_sensor_pin}, - MMU_NEOPIXEL={neopixel_pin}, - - MMU_PRE_GATE_0={pre_gate_0_pin}, - MMU_PRE_GATE_1={pre_gate_1_pin}, - MMU_PRE_GATE_2={pre_gate_2_pin}, - MMU_PRE_GATE_3={pre_gate_3_pin}, - MMU_PRE_GATE_4={pre_gate_4_pin}, - MMU_PRE_GATE_5={pre_gate_5_pin}, - MMU_PRE_GATE_6={pre_gate_6_pin}, - MMU_PRE_GATE_7={pre_gate_7_pin}, - MMU_PRE_GATE_8={pre_gate_8_pin}, - MMU_PRE_GATE_9={pre_gate_9_pin}, - MMU_PRE_GATE_10={pre_gate_10_pin}, - MMU_PRE_GATE_11={pre_gate_11_pin}, - - MMU_POST_GEAR_0={gear_sensor_0_pin}, - MMU_POST_GEAR_1={gear_sensor_1_pin}, - MMU_POST_GEAR_2={gear_sensor_2_pin}, - MMU_POST_GEAR_3={gear_sensor_3_pin}, - MMU_POST_GEAR_4={gear_sensor_4_pin}, - MMU_POST_GEAR_5={gear_sensor_5_pin}, - MMU_POST_GEAR_6={gear_sensor_6_pin}, - MMU_POST_GEAR_7={gear_sensor_7_pin}, - MMU_POST_GEAR_8={gear_sensor_8_pin}, - MMU_POST_GEAR_9={gear_sensor_9_pin}, - MMU_POST_GEAR_10={gear_sensor_10_pin}, - MMU_POST_GEAR_11={gear_sensor_11_pin}, - - MMU_ESPOOLER_RWD_0={espooler_rwd_0_pin}, - MMU_ESPOOLER_FWD_0={espooler_fwd_0_pin}, - MMU_ESPOOLER_EN_0={espooler_en_0_pin}, - MMU_ESPOOLER_TRIG_0=, - MMU_ESPOOLER_RWD_1={espooler_rwd_1_pin}, - MMU_ESPOOLER_FWD_1={espooler_fwd_1_pin}, - MMU_ESPOOLER_EN_1={espooler_en_1_pin}, - MMU_ESPOOLER_TRIG_1=, - MMU_ESPOOLER_RWD_2={espooler_rwd_2_pin}, - MMU_ESPOOLER_FWD_2={espooler_fwd_2_pin}, - MMU_ESPOOLER_EN_2={espooler_en_2_pin}, - MMU_ESPOOLER_TRIG_2=, - MMU_ESPOOLER_RWD_3={espooler_rwd_3_pin}, - MMU_ESPOOLER_FWD_3={espooler_fwd_3_pin}, - MMU_ESPOOLER_EN_3={espooler_en_3_pin}, - MMU_ESPOOLER_TRIG_3=, - diff --git a/klippy/extras/Happy-Hare/config/base/mmu.cfg.kms b/klippy/extras/Happy-Hare/config/base/mmu.cfg.kms deleted file mode 100644 index e0aa6795e7d7..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu.cfg.kms +++ /dev/null @@ -1,33 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Happy Hare MMU hardware pin config -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# This contains MCU connection details for KMS (and KMS buffer) -# Change `serial` to `canbus_uuid: 1234567890` for CANbus setups -# -[mcu mmu] -serial: {serial1} - -[mcu buffer] -serial: {serial2} - -[temperature_sensor MCU_mmu] -sensor_type: temperature_mcu -sensor_mcu: mmu - -[temperature_sensor MCU_buffer] -sensor_type: temperature_mcu -sensor_mcu: buffer - diff --git a/klippy/extras/Happy-Hare/config/base/mmu.cfg.vvd b/klippy/extras/Happy-Hare/config/base/mmu.cfg.vvd deleted file mode 100644 index 61ee7f7698e9..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu.cfg.vvd +++ /dev/null @@ -1,35 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Happy Hare MMU hardware pin config -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# This contains MCU connection details for KMS (and KMS buffer) -# Change `serial` to `canbus_uuid: 1234567890` for CANbus setups -# -[mcu mmu] -serial: {serial1} -#serial: /dev/serial/by-id/usb-Klipper_stm32g0b1xx_4F0034000A50425539393020-if00 - -[mcu buffer] -serial: {serial2} -#serial: /dev/serial/by-id/usb-Klipper_stm32f042x6_buffer-if00 - -[temperature_sensor MCU_mmu] -sensor_type: temperature_mcu -sensor_mcu: mmu - -[temperature_sensor MCU_buffer] -sensor_type: temperature_mcu -sensor_mcu: buffer - diff --git a/klippy/extras/Happy-Hare/config/base/mmu_cut_tip.cfg b/klippy/extras/Happy-Hare/config/base/mmu_cut_tip.cfg deleted file mode 100644 index 4201a6aa774b..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_cut_tip.cfg +++ /dev/null @@ -1,296 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Standalone Tip Cutting for "Filametrix" style toolhead cutters -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# When using this macro it is important to turn off tip forming in your slicer -# (read the wiki: Slicer Setup & Toolchange-Movement pages) -# Then set the following parameters in mmu_parameters.cfg: -# -# form_tip_macro: _MMU_CUT_TIP -# force_form_tip_standalone: 1 -# -# This will ensure this macro is always called either in out of a print -# -# NOTE: -# The park position of the filament is relative to the nozzle and -# represents where the end of the filament is after cutting. The park position -# is important and used by Happy Hare both to finish unloading the extruder -# as well as to calculate how far to advance the filament on the subsequent load. -# It is set dynamically in gcode with this construct: -# SET_GCODE_VARIABLE MACRO=_MMU_CUT_TIP VARIABLE=output_park_pos VALUE=.. -# -[gcode_macro _MMU_CUT_TIP] -description: Cut filament by pressing the cutter on a pin with a horizontal movement - -# -------------------------- Internal Don't Touch ------------------------- -variable_output_park_pos: 0 # Dynamically set in this macro - -gcode: - {% set final_eject = params.FINAL_EJECT|default(0)|int %} - {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set pin_loc_x, pin_loc_y = vars.pin_loc_xy|map('float') %} - {% set pin_park_dist = vars['pin_park_dist']|float %} - {% set retract_length = vars['retract_length']|float %} - {% set simple_tip_forming = vars['simple_tip_forming']|default(true)|lower == 'true' %} - {% set blade_pos = vars['blade_pos']|float %} - {% set rip_length = vars['rip_length']|float %} - {% set pushback_length = vars['pushback_length']|float %} - {% set pushback_dwell_time = vars['pushback_dwell_time']|int %} - {% set extruder_move_speed = vars['extruder_move_speed']|float %} - {% set travel_speed = vars['travel_speed']|float %} - {% set restore_position = vars['restore_position']|default(true)|lower == 'true' %} - {% set extruder_park_pos = blade_pos + rip_length %} - {% set cutting_axis = vars['cutting_axis'] %} - - {% if cutting_axis == "x" %} - {% set pin_park_x_loc = pin_loc_x + pin_park_dist %} - {% set pin_park_y_loc = pin_loc_y %} - {% else %} - {% set pin_park_y_loc = pin_loc_y + pin_park_dist %} - {% set pin_park_x_loc = pin_loc_x %} - {% endif %} - - {% if "xy" not in printer.toolhead.homed_axes %} - MMU_LOG MSG="Automatically homing XY" - G28 X Y - _CUT_TIP_MOVE_IN_BOUNDS - {% endif %} - - SAVE_GCODE_STATE NAME=_MMU_CUT_TIP_state # Save after possible homing operation to prevent 0,0 being recorded - - G90 # Absolute positioning - M83 # Relative extrusion - G92 E0 - - # Step 1 - Calculate initial retract to save filament waste, repeat to allow some cooling - {% set effective_retract_length = retract_length - printer.mmu.extruder_filament_remaining - park_vars.retracted_length %} - {% if effective_retract_length > 0 %} - MMU_LOG MSG="Retracting filament {effective_retract_length|round(1)}mm prior to cut" - G1 E-{effective_retract_length} F{extruder_move_speed * 60} - {% if simple_tip_forming %} - G1 E{effective_retract_length / 2} F{extruder_move_speed * 60} - G1 E-{effective_retract_length / 2} F{extruder_move_speed * 60} - {% endif %} - {% endif %} - - # Step 2 - Perform the cut - _CUT_TIP_ADJUST_CURRENT - _CUT_TIP_MOVE_TO_CUTTER_PIN PIN_PARK_X_LOC={pin_park_x_loc} PIN_PARK_Y_LOC={pin_park_y_loc} - _CUT_TIP_GANTRY_SERVO_DOWN - _CUT_TIP_DO_CUT_MOTION PIN_PARK_X_LOC={pin_park_x_loc} PIN_PARK_Y_LOC={pin_park_y_loc} RIP_LENGTH={rip_length} - _CUT_TIP_GANTRY_SERVO_UP - _CUT_TIP_RESTORE_CURRENT - _MMU_EVENT EVENT="filament_cut" - - # Step 3 - Pushback of the tip residual into the hotend to avoid future catching (ideally past the PTFE/metal boundary) - {% set effective_pushback_length = [pushback_length, retract_length - printer.mmu.extruder_filament_remaining - park_vars.retracted_length]|min %} - {% if effective_pushback_length > 0 %} - MMU_LOG MSG="Pushing filament fragment back {effective_pushback_length|round(1)}mm after cut" - G1 E{effective_pushback_length} F{extruder_move_speed * 60} - G4 P{pushback_dwell_time} - G1 E-{effective_pushback_length} F{extruder_move_speed * 60} - {% endif %} - - # Final eject is for testing - {% if final_eject %} - G92 E0 - G1 E-80 F{extruder_move_speed * 60} - {% endif %} - - # Dynamically set the required output variables for Happy Hare - SET_GCODE_VARIABLE MACRO=_MMU_CUT_TIP VARIABLE=output_park_pos VALUE={extruder_park_pos} - - # Restore state and optionally position (usually on wipetower) - RESTORE_GCODE_STATE NAME=_MMU_CUT_TIP_state MOVE={1 if restore_position else 0} MOVE_SPEED={travel_speed} - -########################################################################### -# Helper macro to alter X/Y stepper current during cut operation -# -[gcode_macro _CUT_TIP_ADJUST_CURRENT] -description: Helper to optionally increase X/Y stepper current prior to cut -variable_current_map: {} # Internal, don't set -gcode: - {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} - {% set cut_axis_steppers = (vars['cut_axis_steppers'] | default('')).split(',') | map('trim') | list %} - {% set cut_stepper_current = vars['cut_stepper_current'] | default(100) | int %} - {% set tmc_types = ["tmc2209", "tmc2130", "tmc2208", "tmc2660", "tmc5160", "tmc2240"] %} - - {% if not current_map %} - {% set ns = namespace(run_current={}) %} - {% if cut_stepper_current != 100 %} - {% for stepper in cut_axis_steppers %} - {% for tmc in tmc_types %} - {% set fullname = tmc ~ ' ' ~ stepper %} - {% if printer[fullname] is defined %} - # Save original run_current and reset to new value - {% set cur_rc = printer[fullname].run_current %} - {% if ns.run_current.update({stepper: cur_rc}) %}{% endif %} - {% set new_rc = cur_rc * cut_stepper_current | float / 100 %} - MMU_LOG MSG="Adjusting {stepper} current" - SET_TMC_CURRENT STEPPER={stepper} CURRENT={new_rc} - {% endif %} - {% endfor %} - {% endfor %} - {% endif %} - - SET_GCODE_VARIABLE MACRO=_CUT_TIP_ADJUST_CURRENT VARIABLE=current_map VALUE="{ns.run_current}" - {% endif %} - -########################################################################### -# Helper macro to restore X/Y stepper current after cutting action -# -[gcode_macro _CUT_TIP_RESTORE_CURRENT] -description: Helper to restore X/Y stepper current after cutting action -gcode: - {% set current_vars = printer['gcode_macro _CUT_TIP_ADJUST_CURRENT'] %} - {% set current_map = current_vars['current_map'] %} - - {% if current_map %} - {% for stepper, current in current_map.items() %} - MMU_LOG MSG="Restoring {stepper} current" - SET_TMC_CURRENT STEPPER={stepper} CURRENT={current} - {% endfor %} - {% endif %} - - SET_GCODE_VARIABLE MACRO=_CUT_TIP_ADJUST_CURRENT VARIABLE=current_map VALUE="{{}}" - - -########################################################################### -# Helper macro to ensure toolhead is in bounds after home in case it is -# used as a restore position point -# -[gcode_macro _CUT_TIP_MOVE_IN_BOUNDS] -description: Helper to move the toolhead to a legal position after homing -gcode: - {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} - {% set travel_speed = vars['travel_speed']|float %} - - {% set pos = printer.gcode_move.gcode_position %} - {% set axis_minimum = printer.toolhead.axis_minimum %} - {% set axis_maximum = printer.toolhead.axis_maximum %} - {% set x = [axis_minimum.x, [axis_maximum.x, pos.x]|min]|max %} - {% set y = [axis_minimum.y, [axis_maximum.y, pos.y]|min]|max %} - - MMU_LOG MSG="Warning: Klipper reported out of range gcode position (x:{pos.x}, y:{pos.y})! Adjusted to (x:{x}, y:{y}) to prevent move failure" ERROR=1 - G1 X{x} Y{y} F{travel_speed * 60} - - -########################################################################### -# Helper macro for tip cutting -# -[gcode_macro _CUT_TIP_MOVE_TO_CUTTER_PIN] -description: Helper to move the toolhead to the target pin in either safe or faster way, depending on toolhead clearance -gcode: - {% set pin_park_x_loc = params.PIN_PARK_X_LOC|float %} - {% set pin_park_y_loc = params.PIN_PARK_Y_LOC|float %} - {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} - - {% set safe_margin_x, safe_margin_y = vars.safe_margin_xy|map('float') %} - {% set travel_speed = vars['travel_speed']|float %} - {% set cutting_axis = vars['cutting_axis'] %} - - {% if ((printer.gcode_move.gcode_position.x - pin_park_x_loc)|abs < safe_margin_x) or ((printer.gcode_move.gcode_position.y - pin_park_y_loc)|abs < safe_margin_y) %} - # Make a safe but slower travel move - {% if cutting_axis == "x" %} - G1 X{pin_park_x_loc} F{travel_speed * 60} - G1 Y{pin_park_y_loc} F{travel_speed * 60} - {% else %} - G1 Y{pin_park_y_loc} F{travel_speed * 60} - G1 X{pin_park_x_loc} F{travel_speed * 60} - {% endif %} - {% else %} - G1 X{pin_park_x_loc} Y{pin_park_y_loc} F{travel_speed * 60} - {% endif %} - - -########################################################################### -# Helper macro for tip cutting -# -[gcode_macro _CUT_TIP_DO_CUT_MOTION] -description: Helper to do a single cut movement -gcode: - {% set pin_park_x_loc = params.PIN_PARK_X_LOC | float %} - {% set pin_park_y_loc = params.PIN_PARK_Y_LOC | float %} - {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} - {% set cutting_axis = vars['cutting_axis'] %} - - {% set pin_loc = vars['pin_loc_compressed']|default(-999)|float %} - {% if pin_loc != -999 %} - # Old one-dimensional pin_loc_compressed config - {% if cutting_axis == "x" %} - {% set pin_loc_compressed_x = pin_loc %} - {% set pin_loc_compressed_y = pin_park_y_loc %} - {% else %} - {% set pin_loc_compressed_x = pin_park_x_loc %} - {% set pin_loc_compressed_y = pin_loc %} - {% endif %} - {% else %} - # New config - {% set pin_loc_compressed_x, pin_loc_compressed_y = vars.pin_loc_compressed_xy|map('float') %} - {% endif %} - - {% set cut_fast_move_fraction = vars['cut_fast_move_fraction']|float %} - {% set cut_fast_move_speed = vars['cut_fast_move_speed']|float %} - {% set cut_slow_move_speed = vars['cut_slow_move_speed']|float %} - {% set cut_dwell_time = vars['cut_dwell_time']|float %} - {% set evacuate_speed = vars['evacuate_speed']|float %} - {% set rip_length = vars['rip_length']|float %} - {% set rip_speed = vars['rip_speed']|float %} - - - {% set fast_slow_transition_loc_x = (pin_loc_compressed_x - pin_park_x_loc) * cut_fast_move_fraction + pin_park_x_loc|float %} - {% set fast_slow_transition_loc_y = (pin_loc_compressed_y - pin_park_y_loc) * cut_fast_move_fraction + pin_park_y_loc|float %} - G1 X{fast_slow_transition_loc_x} Y{fast_slow_transition_loc_y} F{cut_fast_move_speed * 60} # Fast move to initiate contact of the blade with filament - G1 X{pin_loc_compressed_x} Y{pin_loc_compressed_y} F{cut_slow_move_speed * 60} # Do the cut in slow move - - G4 P{cut_dwell_time} - {% if rip_length > 0 %} - G1 E-{rip_length} F{rip_speed * 60} - {% endif %} - - G1 X{pin_park_x_loc} Y{pin_park_y_loc} F{evacuate_speed * 60} # Evacuate - - -########################################################################### -# Helper macro for tip cutting -# -[gcode_macro _CUT_TIP_GANTRY_SERVO_DOWN] -description: Operate optional gantry servo operated pin -gcode: - {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} - {% set gantry_servo_enabled = vars['gantry_servo_enabled']|default(true)|lower == 'true' %} - {% set angle = vars['gantry_servo_down_angle']|float %} - - {% if gantry_servo_enabled %} - SET_SERVO SERVO=mmu_gantry_servo ANGLE={angle} - G4 P500 # Pause to ensure servo is fully down before movement - {% endif %} - - -########################################################################### -# Helper macro for tip cutting -# -[gcode_macro _CUT_TIP_GANTRY_SERVO_UP] -description: Operate optional gantry servo operated pin -gcode: - {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} - {% set gantry_servo_enabled = vars['gantry_servo_enabled']|default(true)|lower == 'true' %} - {% set angle = vars['gantry_servo_up_angle']|float %} - - {% if gantry_servo_enabled %} - SET_SERVO SERVO=mmu_gantry_servo ANGLE={angle} DURATION=0.5 - {% endif %} diff --git a/klippy/extras/Happy-Hare/config/base/mmu_form_tip.cfg b/klippy/extras/Happy-Hare/config/base/mmu_form_tip.cfg deleted file mode 100644 index 090c3721d3c8..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_form_tip.cfg +++ /dev/null @@ -1,178 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Standalone Tip Forming roughly based on Superslicer -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# To configure, set -# 'form_tip_macro: _MMU_FORM_TIP' in 'mmu_parameters.cfg' -# -# This macro is, by default, called by Happy Hare to form filament tip -# prior to unloading. This will need to be tuned for your particular -# setup. Although the slicer can also perform similarly you must also -# tune tips here. The slicer will be used when printing, this logic will be -# used when not in print. Because of the need to setup twice, it is recommended -# that you turn off slicer tip forming and to use this routine in all circumstances. -# -# To force Happy Hare to always run this when loading filament add: -# 'force_form_tip_standalone: 1' in 'mmu_parameters.cfg' -# -# Also decide on whether you want toolhead to remain over wipetower while tool -# changing or move to park location (see 'enable_park' in mmu_sequence.cfg) -# -[gcode_macro _MMU_FORM_TIP] -description: Standalone macro that mimics Superslicer process - -gcode: - {% set final_eject = params.FINAL_EJECT|default(0)|int %} - {% set vars = printer['gcode_macro _MMU_FORM_TIP_VARS'] %} - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set unloading_speed_start = vars['unloading_speed_start']|int %} - {% set unloading_speed = vars['unloading_speed']|int %} - {% set ramming_volume = vars['ramming_volume']|float %} - {% set ramming_volume_standalone = vars['ramming_volume_standalone']|float %} - {% set cooling_tube_length = vars['cooling_tube_length']|float %} - {% set cooling_tube_position = vars['cooling_tube_position']|float %} - {% set initial_cooling_speed = vars['initial_cooling_speed']|int %} - {% set final_cooling_speed = vars['final_cooling_speed']|int %} - {% set cooling_moves = vars['cooling_moves']|int %} - {% set toolchange_temp = vars['toolchange_temp']|default(0)|int %} - {% set use_skinnydip = vars['use_skinnydip']|default(false)|lower == 'true' %} - {% set use_fast_skinnydip = vars['use_fast_skinnydip']|default(false)|lower == 'true' %} - {% set skinnydip_distance = vars['skinnydip_distance']|float %} - {% set dip_insertion_speed = vars['dip_insertion_speed']|int %} - {% set dip_extraction_speed = vars['dip_extraction_speed']|int %} - {% set melt_zone_pause = vars['melt_zone_pause']|default(0)|int %} - {% set cooling_zone_pause = vars['cooling_zone_pause']|default(0)|int %} - {% set extruder_eject_speed = vars['extruder_eject_speed']|int %} - {% set parking_distance = vars['parking_distance']|default(0)|float %} - {% set orig_temp = printer.extruder.target %} - {% set next_temp = params.NEXT_TEMP|default(orig_temp)|int %} - - # Useful state for customizing operations depending on mode - {% set runout = printer.mmu.runout %} - {% set printing = printer.mmu.print_state == 'printing' %} - - SAVE_GCODE_STATE NAME=MMU_FORM_TIP_state - - G91 # Relative positioning - M83 # Relative extrusion - G92 E0 - - # Step 1 - Ramming - # This is very generic and unlike slicer does not incorporate moves on the wipetower - {% set ramming_volume = ramming_volume_standalone if not printing else ramming_volume %} - {% if ramming_volume > 0 %} # Standalone Ramming - {% set ratio = ramming_volume / 23.0 %} - G1 E{0.5784 * ratio} F299 #7 - G1 E{0.5834 * ratio} F302 #3 - G1 E{0.5918 * ratio} F306 #6 - G1 E{0.6169 * ratio} F319 #6 - G1 E{0.3393 * ratio} F350 #0 - G1 E{0.3363 * ratio} F350 #0 - G1 E{0.7577 * ratio} F392 #6 - G1 E{0.8382 * ratio} F434 #3 - G1 E{0.7776 * ratio} F469 #9 - G1 E{0.1293 * ratio} F469 #9 - G1 E{0.9673 * ratio} F501 #2 - G1 E{1.0176 * ratio} F527 #2 - G1 E{0.5956 * ratio} F544 #6 - G1 E{0.4555 * ratio} F544 #6 - G1 E{1.0662 * ratio} F552 #4 - {% endif %} - - # Step 2 - Retraction / Nozzle Separation - # This is where the tip spear shape comes from. Faster=longer/pointer/higher stringing - {% set total_retraction_distance = cooling_tube_position - printer.mmu.extruder_filament_remaining - park_vars.retracted_length + cooling_tube_length - 15 %} - G1 E-15 F{1.0 * unloading_speed_start * 60} # Fixed default value from SS - {% if total_retraction_distance > 0 %} - G1 E-{(0.7 * total_retraction_distance)|round(2)} F{1.0 * unloading_speed * 60} - G1 E-{(0.2 * total_retraction_distance)|round(2)} F{0.5 * unloading_speed * 60} - G1 E-{(0.1 * total_retraction_distance)|round(2)} F{0.3 * unloading_speed * 60} - {% endif %} - - # Set toolchange temperature just prior to cooling moves (not fast skinnydip mode) - {% if toolchange_temp > 0 %} - M104 S{toolchange_temp} - {% if not use_fast_skinnydip %} - _WAIT_FOR_TEMP - {% endif %} - {% endif %} - - # Step 3 - Cooling Moves - # Solidifies tip shape and helps break strings if formed - {% set speed_inc = (final_cooling_speed - initial_cooling_speed) / (2 * cooling_moves - 1) %} - {% for move in range(cooling_moves) %} - {% set speed = initial_cooling_speed + speed_inc * move * 2 %} - G1 E{cooling_tube_length} F{speed * 60} - G1 E-{cooling_tube_length} F{(speed + speed_inc) * 60} - {% endfor %} - - # Wait for extruder to reach toolchange temperature after cooling moves complete (fast skinnydip only) - {% if toolchange_temp > 0 and use_skinnydip and use_fast_skinnydip %} - _WAIT_FOR_TEMP - {% endif %} - - # Step 4 - Skinnydip - # Burns off very fine hairs (Good for PLA) - {% if use_skinnydip %} - G1 E{skinnydip_distance} F{dip_insertion_speed * 60} - G4 P{melt_zone_pause} - G1 E-{skinnydip_distance} F{dip_extraction_speed * 60} - G4 P{cooling_zone_pause} - {% endif %} - - # Set temperature target to next filament temp or starting temp. Note that we don't - # wait because the temp will settle during the rest of the toolchange - M104 S{next_temp} - - # Step 5 - Parking - # Optional park filament at fixed location or eject completely (testing) - {% if final_eject %} - G92 E0 - G1 E-80 F{extruder_eject_speed * 60} - {% elif parking_distance > 0 %} - G90 # Absolute positioning - M82 # Absolute extrusion - G1 E-{parking_distance} F{extruder_eject_speed * 60} - {% endif %} - - # Restore state - RESTORE_GCODE_STATE NAME=MMU_FORM_TIP_state - - -[gcode_macro _WAIT_FOR_TEMP] -description: Helper function for fan assisted extruder temp reduction -gcode: - {% set vars = printer['gcode_macro _MMU_FORM_TIP_VARS'] %} - {% set toolchange_temp = vars['toolchange_temp']|default(0)|int %} - {% set toolchange_use_fan = vars['toolchange_fan_assist']|default(false)|lower == 'true' %} - {% set toolchange_fan_speed = vars['toolchange_fan_speed']|default(50)|int %} - {% set toolchange_fan = vars['toolchange_fan_name']|default('')|string %} - - MMU_LOG MSG='{"Waiting for extruder temp %d\u00B0C..." % toolchange_temp}' - {% if toolchange_use_fan %} - {% if printer.fan is defined or printer[toolchange_fan] is defined %} - {% set orig_fan_speed = printer[toolchange_fan].speed if printer[toolchange_fan] is defined else printer.fan.speed %} - M106 S{(toolchange_fan_speed / 100 * 255)|int} - M109 S{toolchange_temp} - M106 S{(orig_fan_speed * 255)|int} - {% else %} - MMU_LOG MSG="Warning: Printer part fan is not defined. Ignoring 'toolchange_use_fan' option" ERROR=1 - M109 S{toolchange_temp} - {% endif %} - {% else %} - M109 S{toolchange_temp} - {% endif %} - diff --git a/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg b/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg deleted file mode 100644 index 6982e59b9ccc..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg +++ /dev/null @@ -1,485 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Happy Hare MMU hardware config file with config for {brd_type} MCU board -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# Notes about setup of common external MCUs can be found here: -# https://github.com/moggieuk/Happy-Hare/blob/main/doc/mcu_notes.md -# -# Note about "touch" endstops: Happy Hare provides extremely flexible homing options using both single steppers or -# synced steppers. The "touch" option leverages stallguard and thus requires the appropriate 'diag_pin' and stallguard -# parameters set on the TMC driver section. If you have the diag_pin exposed, it is harmless to define this because -# they will only be used when explicitly needed and configured. -# -# Touch option for each stepper provides these benefits / possibilities (experimental): -# - on extruder stepper allows for the automatic detection of the nozzle! -# - on selector stepper allows for the automatic detection of filament stuck in the gate and subsequent recovery -# - on gear stepper allows for the automatic detection of the extruder entrance -# -# In summary, "touch" homing with your MMU is an advanced option that requires patience and careful tuning. Everything -# works with regular endstops and there are workaround options for certain homing points (like extruder entry) in -# the absence of any endstop. I'm really interested in creative setups. Ping me on Discord (moggieuk#6538) -# -# See 'mmu.cfg' for serial definition and pins aliases -# -# HOMING CAPABLE EXTRUDER (VERY ADVANCED) ----------------------------------------------------------------------------- -# With Happy Hare installed even the extruder can be homed. You will find the usual 'endstop' parameters can be added -# to your '[extruder]' section. Useless you have some clever load cell attached to your nozzle it only really makes -# sense to configure stallguard style "touch" homing. To do this add lines similar to this to your existing -# '[extruder]' definition in printer.cfg. -# -# [extruder] -# endstop_pin: tmc2209_extruder:virtual_endstop -# -# Also be sure to add the appropriate stallguard config to the TMC section, e.g. -# -# [tmc2209 extruder] -# diag_pin: E_DIAG # Set to MCU pin connected to TMC DIAG pin for extruder -# driver_SGTHRS: 100 # 255 is most sensitive value, 0 is least sensitive -# -# Happy Hare will take care of the rest and add a 'mmu_ext_touch' endstop automatically -# - - -# MMU MACHINE / TYPE --------------------------------------------------------------------------------------------------- -# ███╗ ███╗███╗ ███╗██╗ ██╗ ███╗ ███╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗███████╗ -# ████╗ ████║████╗ ████║██║ ██║ ████╗ ████║██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝ -# ██╔████╔██║██╔████╔██║██║ ██║ ██╔████╔██║███████║██║ ███████║██║██╔██╗ ██║█████╗ -# ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ ██║╚██╔╝██║██╔══██║██║ ██╔══██║██║██║╚██╗██║██╔══╝ -# ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║██║██║ ╚████║███████╗ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ -[mmu_machine] - -# Number of selectable gate on (each) MMU. Generally this is a single number, but with multi-mmu (type-B) setups -# it can be a comma separated list of the number of gates per unit. -# E.g. 'num_gates: 4,4,2' for a 2xBox Turtle and 1xNight Owl multiplexed setup -# -num_gates: {num_gates} - -# MMU Vendor & Version is used to automatically configure some parameters and validate configuration -# If custom set to "Other" and uncomment the additional parameters below -# -# ERCF 1.1 add "s" suffix for Springy, "b" for Binky, "t" for Triple-Decky -# e.g. "1.1sb" for v1.1 with Springy mod and Binky encoder -# ERCF 2.0 community edition ERCFv2 -# ERCF 3.0 community edition ERCFv3 -# Tradrack 1.0 add "e" if encoder is fitted (assumed to be Binky) -# AngryBeaver -# BoxTurtle -# NightOwl -# 3MS -# QuattroBox 1.0 -# QuattroBox 2.0 revised LEDs -# 3D Chameleon -# Pico -# MMX -# BTT ViViD -# KMS -# EMU -# Prusa 3.0 NOT YET SUPPORTED - COMING SOON -# Other Generic setup that may require further customization of 'cad' parameters. See doc in mmu_parameters.cfg -# -mmu_vendor: {mmu_vendor} # MMU family -mmu_version: {mmu_version} # MMU hardware version number (add mod suffix documented above) - -# The following attributes are set internally from vendor/version above. Only uncomment to customize the vendor -# default or for custom ("Other") designs -# -#selector_type: {selector_type} # E.g. LinearServoSelector (type-A), VirtualSelector (type-B), MacroSelector, RotarySelector, ... -#variable_bowden_lengths: {variable_bowden_lengths} # 1 = If MMU design has different bowden lengths per gate, 0 = bowden length is the same -#variable_rotation_distances: {variable_rotation_distances} # 1 = If MMU design has dissimilar drive/BMG gears, thus rotation distance, 0 = One drive gear (e.g. Tradrack) -#require_bowden_move: {require_bowden_move} # 1 = If MMU design has bowden move that is included in load/unload, 0 = zero length bowden (skip bowden move) -#filament_always_gripped: {filament_always_gripped} # 1 = Filament is always trapped by MMU (most type-B designs), 0 = MMU can release filament -#has_bypass: {has_bypass} # 1 = Integrated bypass gate available, 0 = No integrated bypass; Bypassing only possible via PTFE - -homing_extruder: 1 # CAUTION: Normally this should be 1. 0 will disable the homing extruder capability - -# Uncomment to change the display name in UI's. Defaults to the vendor name -#display_name: My Precious - -# Full name of environment sensor object for MMU filament storage (displays temp and humidity in UI). -# Leave empty if sensor is not fitted. Polls for "temperature" and "humidity" -# E.g. If you have a section like this: [temperature_sensor MMU_enclosure] -# Set: environment_sensor: temperature_sensor MMU_enclosure -environment_sensor: - -# Full name of filament heater object. Leave empty if heater is not fitted -# E.g. If you have a section like this: [heater_generic MMU_heater] -# Set: filament_heater: heater_generic MMU_heater -filament_heater: - -# PER-GATE heaters and environment sensors. Some modular MMU designs have per-gate control (e.g. EMU). For this type of MMU, leave -# 'environment_sensor' and 'filament_heater' empty and define a list of heaters and sensors here. Note the length of the list must -# match the number of gates. Leave this settings empty if defining a single sensor/heater. -environment_sensors: # Comma separated list of full environment sensor object names -filament_heaters: # Comma separated list of full heater object names -max_concurrent_heaters: 1 # Limit the number of simultaneously active heaters (power/PSU protection) - - -# FILAMENT DRIVE GEAR STEPPER(S) -------------------------------------------------------------------------------------- -# ██████╗ ███████╗ █████╗ ██████╗ -# ██╔════╝ ██╔════╝██╔══██╗██╔══██╗ -# ██║ ███╗█████╗ ███████║██████╔╝ -# ██║ ██║██╔══╝ ██╔══██║██╔══██╗ -# ╚██████╔╝███████╗██║ ██║██║ ██║ -# ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ -# Note that 'toolhead' & 'mmu_gear' endstops will automatically be added if a toolhead sensor or gate sensor is defined -# -# The default values are tested with the ERCF BOM NEMA14 motor. Please adapt these values to the motor you are using -# Example : for NEMA17 motors, you'll usually use higher current -# -[tmc2209 stepper_mmu_gear] -uart_pin: mmu:MMU_GEAR_UART -uart_address: 0 # Only for old EASY-BRD mcu -run_current: {gear_run_current} # ERCF BOM NEMA14 motor -hold_current: {gear_hold_current} # Recommend to be small if not using "touch" or move (TMC stallguard) -interpolate: True -sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 -stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed -# -# Uncomment two lines below if you have TMC and want the ability to use filament "touch" homing with gear stepper -#diag_pin: ^mmu:MMU_GEAR_DIAG # Set to MCU pin connected to TMC DIAG pin for gear stepper -#driver_SGTHRS: 60 # 255 is most sensitive value, 0 is least sensitive - -[stepper_mmu_gear] -step_pin: mmu:MMU_GEAR_STEP -dir_pin: !mmu:MMU_GEAR_DIR -enable_pin: !mmu:MMU_GEAR_ENABLE -rotation_distance: 22.7316868 # Bondtech 5mm Drive Gears. Overridden by 'mmu_gear_rotation_distance' in mmu_vars.cfg -gear_ratio: {gear_gear_ratio} # E.g. ERCF 80:20, Tradrack 50:17 -microsteps: 16 # Recommend 16. Increase only if you "step compress" issues when syncing -full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree -# -# Uncomment the two lines below to enable filament "touch" homing option with gear motor -#extra_endstop_pins: tmc2209_stepper_mmu_gear:virtual_endstop -#extra_endstop_names: mmu_gear_touch - -# ADDITIONAL FILAMENT DRIVE GEAR STEPPERS FOR TYPE-B MMU's ------------------------------------------------------------- -# Note that common parameters are inherited from base stepper_mmu_gear, but can be uniquely specified here too -# -# Filament Drive Gear_1 -------------------------- -[tmc2209 stepper_mmu_gear_1] -uart_pin: mmu:MMU_GEAR_UART_1 - -[stepper_mmu_gear_1] -step_pin: mmu:MMU_GEAR_STEP_1 -dir_pin: !mmu:MMU_GEAR_DIR_1 -enable_pin: !mmu:MMU_GEAR_ENABLE_1 - - -# SELECTOR STEPPER ---------------------------------------------------------------------------------------------------- -# ███████╗███████╗██╗ ███████╗ ██████╗████████╗ ██████╗ ██████╗ -# ██╔════╝██╔════╝██║ ██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ -# ███████╗█████╗ ██║ █████╗ ██║ ██║ ██║ ██║██████╔╝ -# ╚════██║██╔══╝ ██║ ██╔══╝ ██║ ██║ ██║ ██║██╔══██╗ -# ███████║███████╗███████╗███████╗╚██████╗ ██║ ╚██████╔╝██║ ██║ -# ╚══════╝╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ -# Consult doc if you want to setup selector for "touch" homing instead or physical endstop -# -[tmc2209 stepper_mmu_selector] -uart_pin: mmu:MMU_SEL_UART -uart_address: 1 # Only for EASY-BRD -run_current: {sel_run_current} # ERCF BOM NEMA17 motor -hold_current: {sel_hold_current} # Can be small if not using "touch" movement (TMC stallguard) -interpolate: True -sense_resistor: 0.110 -stealthchop_threshold: 100 # Stallguard "touch" movement (slower speeds) best done with stealthchop -# -# Uncomment two lines below if you have TMC and want to use selector "touch" movement -#diag_pin: ^mmu:MMU_SEL_DIAG # Set to MCU pin connected to TMC DIAG pin for selector stepper -#driver_SGTHRS: 75 # 255 is most sensitive value, 0 is least sensitive - -[stepper_mmu_selector] -step_pin: mmu:MMU_SEL_STEP -dir_pin: !mmu:MMU_SEL_DIR -enable_pin: !mmu:MMU_SEL_ENABLE -rotation_distance: 40 -microsteps: 16 # Don't need high fidelity -gear_ratio: {sel_gear_ratio} -full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree -endstop_pin: ^mmu:MMU_SEL_ENDSTOP # Selector microswitch -endstop_name: mmu_sel_home -# Uncomment this line only if default endstop above is using stallguard -#homing_retract_dist: 0 -# -# Uncomment two lines below to give option of selector "touch" movement -#extra_endstop_pins: tmc2209_stepper_mmu_selector:virtual_endstop -#extra_endstop_names: mmu_sel_touch - - -# SERVOS --------------------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ ███████╗ -# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗██╔════╝ -# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║███████╗ -# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║╚════██║ -# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝███████║ -# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ -# Basic servo PWM setup. If these values are changed then the angles defined for different positions will also change -# -# SELECTOR SERVO ------------------------------------------------------------------------------------------------------- -# -[mmu_servo selector_servo] -pin: mmu:MMU_SEL_SERVO -maximum_servo_angle: {maximum_servo_angle} -minimum_pulse_width: {minimum_pulse_width} -maximum_pulse_width: {maximum_pulse_width} -# -# OPTIONAL GANTRY SERVO FOR TOOLHEAD FILAMENT CUTTER ------------------------------------------------------------------ -# -# (uncomment this section if you have a gantry servo for toolhead cutter pin) -#[mmu_servo mmu_gantry_servo] -#pin: {gantry_servo_pin} -#maximum_servo_angle:180 -#minimum_pulse_width: 0.00075 -#maximum_pulse_width: 0.00225 -#initial_angle: 180 - - -# FILAMENT SENSORS ----------------------------------------------------------------------------------------------------- -# ███████╗███████╗███╗ ██╗███████╗ ██████╗ ██████╗ ███████╗ -# ██╔════╝██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ -# ███████╗█████╗ ██╔██╗ ██║███████╗██║ ██║██████╔╝███████╗ -# ╚════██║██╔══╝ ██║╚██╗██║╚════██║██║ ██║██╔══██╗╚════██║ -# ███████║███████╗██║ ╚████║███████║╚██████╔╝██║ ██║███████║ -# ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ -# Define the pins for optional sensors in the filament path. All but the pre-gate sensors will be automatically setup as -# both endstops (for homing) and sensors for visibility purposes. -# -# 'pre_gate_switch_pin_X' .. 'mmu_pre_gate_X' sensor detects filament at entry to MMU. X=gate number (0..N) -# 'gate_switch_pin' .. 'mmu_gate' shared sensor detects filament past the gate of the MMU -# or -# 'post_gear_switch_pin_X' .. 'mmu_gear_X' post gear sensor for each filament -# 'extruder_switch_pin' .. 'extruder' sensor detects filament just before the extruder entry -# 'toolhead_switch_pin' .. 'toolhead' sensor detects filament after extruder entry -# -# Sync motor feedback will typically have a tension switch (most important for syncing) or both tension and compression. -# Note that compression switch is useful for use as a endstop to detect hitting the extruder entrance -# 'sync_feedback_tension_pin' .. pin for switch activated when filament is under tension -# 'sync_feedback_compression_pin' .. pin for switch activated when filament is under compression -# -# Configuration is flexible: Simply define pins for any sensor you want to enable, if pin is not set (or the alias is empty) -# it will be ignored. You can also just comment out what you are not using. -# -[mmu_sensors] -pre_gate_switch_pin_0: ^mmu:MMU_PRE_GATE_0 -pre_gate_switch_pin_1: ^mmu:MMU_PRE_GATE_1 -pre_gate_switch_pin_2: ^mmu:MMU_PRE_GATE_2 -pre_gate_switch_pin_3: ^mmu:MMU_PRE_GATE_3 -pre_gate_switch_pin_4: ^mmu:MMU_PRE_GATE_4 -pre_gate_switch_pin_5: ^mmu:MMU_PRE_GATE_5 -pre_gate_switch_pin_6: ^mmu:MMU_PRE_GATE_6 -pre_gate_switch_pin_7: ^mmu:MMU_PRE_GATE_7 -pre_gate_switch_pin_8: ^mmu:MMU_PRE_GATE_8 -pre_gate_switch_pin_9: ^mmu:MMU_PRE_GATE_9 -pre_gate_switch_pin_10: ^mmu:MMU_PRE_GATE_10 -pre_gate_switch_pin_11: ^mmu:MMU_PRE_GATE_11 - -post_gear_switch_pin_0: ^mmu:MMU_POST_GEAR_0 -post_gear_switch_pin_1: ^mmu:MMU_POST_GEAR_1 -post_gear_switch_pin_2: ^mmu:MMU_POST_GEAR_2 -post_gear_switch_pin_3: ^mmu:MMU_POST_GEAR_3 -post_gear_switch_pin_4: ^mmu:MMU_POST_GEAR_4 -post_gear_switch_pin_5: ^mmu:MMU_POST_GEAR_5 -post_gear_switch_pin_6: ^mmu:MMU_POST_GEAR_6 -post_gear_switch_pin_7: ^mmu:MMU_POST_GEAR_7 -post_gear_switch_pin_8: ^mmu:MMU_POST_GEAR_8 -post_gear_switch_pin_9: ^mmu:MMU_POST_GEAR_9 -post_gear_switch_pin_10: ^mmu:MMU_POST_GEAR_10 -post_gear_switch_pin_11: ^mmu:MMU_POST_GEAR_11 - -# These sensors can be replicated in a multi-mmu, type-B setup (see num_gates comment). -# If so, then use a comma separated list of per-unit pins instead of single pin -# -gate_switch_pin: ^mmu:MMU_GATE_SENSOR -sync_feedback_tension_pin: {sync_feedback_tension_pin} -sync_feedback_compression_pin: {sync_feedback_compression_pin} - -# Proportional sync feedback sensor configuration. Leave empty if not fitted. -# (if you have a proportional sensor the sync_feedback_tension_pin and sync_feedback_compression_pin would likely be empty) -# -sync_feedback_analog_pin: # The ADC pin where the proportional filament pressure sensor is installed -sync_feedback_analog_max_compression: 1 # Raw sensor reading at max filament compression (buffer squeezed) -sync_feedback_analog_max_tension: 0 # Raw sensor reading at max filament tension (buffer expanded) -sync_feedback_analog_neutral_point: 0.50 # Biasing of neutral point (sensor value 0). Normally close to 0.5 - -# These sensors are on the toolhead and often controlled by the main printer mcu -# -extruder_switch_pin: {extruder_sensor_pin} -toolhead_switch_pin: {toolhead_sensor_pin} - - -# ENCODER ------------------------------------------------------------------------------------------------------------- -# ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ -# ██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗ -# █████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗ ██████╔╝ -# ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗ -# ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗██║ ██║ -# ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ -# Encoder measures distance, monitors for runout and clogging and constantly calculates % flow rate -# Note that the encoder_resolution set here is purely a default to get started. It will be correcly set after calibration -# with the value stored in mmu_vars.cfg -# -# The encoder resolution will be calibrated but it needs a default approximation -# If BMG gear based: -# resolution = bmg_circumfrance / (2 * teeth) -# 24 / (2 * 17) = 0.7059 for TRCT5000 based sensor -# 24 / (2 * 12) = 1.0 for Binky with 12 tooth disc -# -[mmu_encoder mmu_encoder] -encoder_pin: ^mmu:MMU_ENCODER -encoder_resolution: {encoder_resolution} # This is just a starter value. Overriden by calibrated 'mmu_encoder_resolution' in mmm_vars.cfg -desired_headroom: 5.0 # The clog/runout headroom that MMU attempts to maintain (closest point to triggering runout) -average_samples: 4 # The "damping" effect of last measurement (higher value means slower automatic clog_length reduction) -flowrate_samples: 20 # How many "movements" of the extruder to measure average flowrate over - - -# ESPOOLER (OPTIONAL) ------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ -# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ -# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ -# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ -# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ -# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ -# -# An espooler controls DC motors (typically N20) that are able to rewind a filament spool and optionally provide -# forward assist to overcome spooler rotation friction. This should define pins for each of the gates on your mmu -# starting with '_0'. -# An empty pin can be deleted, commented or simply left blank. If you mcu has a separate "enable" pin -# -[mmu_espooler mmu_espooler] -pwm: 1 # 1=PWM control (typical), 0=digital on/off control -#hardware_pwm: 0 # See klipper doc -#cycle_time: 0.100 # See klipper doc -scale: 1 # Scales the PWM output range -#value: 0 # See klipper doc -#shutdown_value: 0 # See klipper doc - -respool_motor_pin_0: mmu:MMU_ESPOOLER_RWD_0 # PWM (or digital) pin for rewind/respool movement -assist_motor_pin_0: mmu:MMU_ESPOOLER_FWD_0 # PWM (or digital) pin for forward motor movement -enable_motor_pin_0: mmu:MMU_ESPOOLER_EN_0 # Digital output for Afc mcu -assist_trigger_pin_0: mmu:MMU_ESPOOLER_TRIG_0 # Trigger pin for sensing need to assist during print - -respool_motor_pin_1: mmu:MMU_ESPOOLER_RWD_1 -assist_motor_pin_1: mmu:MMU_ESPOOLER_FWD_1 -enable_motor_pin_1: mmu:MMU_ESPOOLER_EN_1 -assist_trigger_pin_1: mmu:MMU_ESPOOLER_TRIG_1 - -respool_motor_pin_2: mmu:MMU_ESPOOLER_RWD_2 -assist_motor_pin_2: mmu:MMU_ESPOOLER_FWD_2 -enable_motor_pin_2: mmu:MMU_ESPOOLER_EN_2 -assist_trigger_pin_2: mmu:MMU_ESPOOLER_TRIG_2 - -respool_motor_pin_3: mmu:MMU_ESPOOLER_RWD_3 -assist_motor_pin_3: mmu:MMU_ESPOOLER_FWD_3 -enable_motor_pin_3: mmu:MMU_ESPOOLER_EN_3 -assist_trigger_pin_3: mmu:MMU_ESPOOLER_TRIG_3 - - -# MMU OPTIONAL NEOPIXEL LED SUPPORT ------------------------------------------------------------------------------------ -# ██╗ ███████╗██████╗ ███████╗ -# ██║ ██╔════╝██╔══██╗██╔════╝ -# ██║ █████╗ ██║ ██║███████╗ -# ██║ ██╔══╝ ██║ ██║╚════██║ -# ███████╗███████╗██████╔╝███████║ -# ╚══════╝╚══════╝╚═════╝ ╚══════╝ -# Define the led connection, type and length -# -# (comment out this section if you don't have leds or have them defined elsewhere) -[neopixel mmu_leds] -pin: mmu:MMU_NEOPIXEL -chain_count: {chain_count} # Need number gates x1 or x2 + status leds -color_order: {color_order} # Set based on your particular neopixel specification (can be comma separated list) - -# MMU LED EFFECT SEGMENTS ---------------------------------------------------------------------------------------------- -# Define neopixel LEDs for your MMU. The chain_count must be large enough for your desired ranges: -# exit .. this set of LEDs, one for every gate, usually would be mounted at the exit point of the gate -# entry .. this set of LEDs, one for every gate, could be mounted at the entry point of filament into the MMU/buffer -# status .. these LED. represents the status of the MMU (and selected filament). More than one status LED is possible -# logo .. these LEDs don't change during operation and are designed for driving a logo. More than one logo LED is possible -# -# Note that all sets are optional. You can opt to just have the 'exit' set for example. The advantage to having -# both entry and exit LEDs is, for example, so that 'entry' can display gate status while 'exit' displays the color -# -# LED's are indexed in the chain from 1..N. Thus to set up LED's on 'exit' and a single 'status' LED on a 4 gate MMU: -# -# exit_leds: neopixel:mmu_leds (1,2,3,4) -# status_leds: neopixel:mmu_leds (5) -# -# In this example no 'entry' set is configured. Note that constructs like "mmu_leds (1-3,4)" are also valid -# -# The range is completely flexible and can be comprised of different led strips, individual LEDs, or combinations of -# both on different pins. In addition, the ordering is flexible based on your wiring, thus (1-4) and (4-1) both represent -# the same LED range but mapped to increasing or decreasing gates respectively. E.g if you have two Box Turtle MMUs, one -# with a chain of LEDs wired in reverse order and another with individual LEDs, to define 8 exit LEDs: -# -# exit_leds: neopixel:bt_1 (4-1) -# neopixel:bt_2a -# neopixel:bt_2b -# neopixel:bt_2c -# neopixel:bt_2d -# -# Note the use of separate lines for each part of the definition, -# -# ADVANCED: Happy Hare provides a convenience wrapper [mmu_led_effect] that not only creates an effect on each of the -# [mmu_leds] specified segments as a whole but also each individual LED for atomic control. See mmu_leds.cfg for examples -# -# (comment out this whole section if you don't have/want leds; uncomment/edit LEDs fitted on your MMU) -[mmu_leds] -exit_leds: {exit_leds} -entry_leds: {entry_leds} -status_leds: {status_leds} -logo_leds: {logo_leds} -frame_rate: 24 - -# Default effects for LED segments when not providing action status -# off - LED's off -# on - LED's white -# gate_status - indicate gate availability / status (printer.mmu.gate_status) -# filament_color - display filament color defined in gate map (printer.mmu.gate_color_rgb) -# slicer_color - display slicer defined set color for each gate (printer.mmu.slicer_color_rgb) -# (r,g,b) - display static r,g,b color e.g. "0,0,0.3" for dim blue -# _effect_ - display the named led effect -# -enabled: True # True = LEDs are enabled at startup (MMU_LED can control), False = Disabled -animation: True # True = Use led-animation-effects, False = Static LEDs -exit_effect: gate_status # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -entry_effect: filament_color # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -status_effect: filament_color # on|off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -logo_effect: (0, 0, 0.3) # off |r,g,b|_effect_ -white_light: (1, 1, 1) # RGB color for static white light -black_light: (.01, 0, .02) # RGB color used to represent "black" (filament) -empty_light: (0, 0, 0) # RGB color used to represent empty gate - -# Default effects (animation: True) / static rbg (animation False) to apply to actions -# effect_name, (r,b,g) -# -# IMPORTANT: Effects must be from [mmu_led_effects] set defined in mmu_leds.cfg -# -effect_loading: mmu_blue_clockwise_slow, (0, 0, 0.4) -effect_loading_extruder: mmu_blue_clockwise_fast, (0, 0, 1) -effect_unloading: mmu_blue_anticlock_slow, (0, 0, 0.4) -effect_unloading_extruder: mmu_blue_anticlock_fast, (0, 0, 1) -effect_heating: mmu_breathing_red, (0.3, 0, 0) -effect_selecting: mmu_white_fast, (0.2, 0.2, 0.2) -effect_checking: mmu_white_fast, (0.8, 0.8, 0.8) -effect_initialized: mmu_rainbow, (0.5, 0.2, 0) -effect_error: mmu_strobe, (1, 0, 0) -effect_complete: mmu_sparkle, (0.3, 0.3, 0.3) -effect_gate_selected: mmu_static_blue, (0, 0, 1) -effect_gate_available: mmu_static_green, (0, 0.5, 0) -effect_gate_available_sel: mmu_ready_green, (0, 0.75, 0) -effect_gate_unknown: mmu_static_orange, (0.5, 0.2, 0) -effect_gate_unknown_sel: mmu_ready_orange, (0.75, 0.3, 0) -effect_gate_empty: mmu_static_black, (0, 0, 0) -effect_gate_empty_sel: mmu_ready_orange2, (0.1, 0.04, 0) diff --git a/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg.kms b/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg.kms deleted file mode 100644 index 3ff5c1adb1ec..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg.kms +++ /dev/null @@ -1,477 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Happy Hare MMU hardware config file with config for KMS MCU board -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# Notes about setup of common external MCUs can be found here: -# https://github.com/moggieuk/Happy-Hare/blob/main/doc/mcu_notes.md -# -# Note about "touch" endstops: Happy Hare provides extremely flexible homing options using both single steppers or -# synced steppers. The "touch" option leverages stallguard and thus requires the appropriate 'diag_pin' and stallguard -# parameters set on the TMC driver section. If you have the diag_pin exposed, it is harmless to define this because -# they will only be used when explicitly needed and configured. -# -# Touch option for each stepper provides these benefits / possibilities (experimental): -# - on extruder stepper allows for the automatic detection of the nozzle! -# - on selector stepper allows for the automatic detection of filament stuck in the gate and subsequent recovery -# - on gear stepper allows for the automatic detection of the extruder entrance -# -# In summary, "touch" homing with your MMU is an advanced option that requires patience and careful tuning. Everything -# works with regular endstops and there are workaround options for certain homing points (like extruder entry) in -# the absence of any endstop. I'm really interested in creative setups. Ping me on Discord (moggieuk#6538) -# -# See 'mmu.cfg' for serial definition and pins aliases -# -# HOMING CAPABLE EXTRUDER (VERY ADVANCED) ----------------------------------------------------------------------------- -# With Happy Hare installed even the extruder can be homed. You will find the usual 'endstop' parameters can be added -# to your '[extruder]' section. Useless you have some clever load cell attached to your nozzle it only really makes -# sense to configure stallguard style "touch" homing. To do this add lines similar to this to your existing -# '[extruder]' definition in printer.cfg. -# -# [extruder] -# endstop_pin: tmc2209_extruder:virtual_endstop -# -# Also be sure to add the appropriate stallguard config to the TMC section, e.g. -# -# [tmc2209 extruder] -# diag_pin: E_DIAG # Set to MCU pin connected to TMC DIAG pin for extruder -# driver_SGTHRS: 100 # 255 is most sensitive value, 0 is least sensitive -# -# Happy Hare will take care of the rest and add a 'mmu_ext_touch' endstop automatically -# - - -# MMU MACHINE / TYPE --------------------------------------------------------------------------------------------------- -# ███╗ ███╗███╗ ███╗██╗ ██╗ ███╗ ███╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗███████╗ -# ████╗ ████║████╗ ████║██║ ██║ ████╗ ████║██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝ -# ██╔████╔██║██╔████╔██║██║ ██║ ██╔████╔██║███████║██║ ███████║██║██╔██╗ ██║█████╗ -# ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ ██║╚██╔╝██║██╔══██║██║ ██╔══██║██║██║╚██╗██║██╔══╝ -# ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║██║██║ ╚████║███████╗ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ -[mmu_machine] - -# Number of selectable gate on (each) MMU. Generally this is a single number, but with multi-mmu (type-B) setups -# it can be a comma separated list of the number of gates per unit. -# E.g. 'num_gates: 4,4,2' for a 2xBox Turtle and 1xNight Owl multiplexed setup -# -num_gates: 4 - -# MMU Vendor & Version is used to automatically configure some parameters and validate configuration -# If custom set to "Other" and uncomment the additional parameters below -# -# ERCF 1.1 add "s" suffix for Springy, "b" for Binky, "t" for Triple-Decky -# e.g. "1.1sb" for v1.1 with Springy mod and Binky encoder -# ERCF 2.0 community edition ERCFv2 -# ERCF 2.5 -# Tradrack 1.0 add "e" if encoder is fitted (assumed to be Binky) -# AngryBeaver 1.0 -# BoxTurtle 1.0 -# NightOwl 1.0 -# 3MS 1.0 -# 3D Chameleon 1.0 -# Pico 1.0 -# Prusa 3.0 NOT YET SUPPORTED - COMING SOON -# Other Generic setup that may require further customization of 'cad' parameters. See doc in mmu_parameters.cfg -# -mmu_vendor: KMS # MMU family -mmu_version: 1.0 # MMU hardware version number (add mod suffix documented above) - -# The following attributes are set internally from vendor/version above. Only uncomment to customize the vendor -# default or for custom ("Other") designs -# -#selector_type: VirtualSelector # E.g. LinearSelector (type-A), VirtualSelector (type-B), MacroSelector, RotarySelector, ... -#variable_bowden_lengths: 0 # 1 = If MMU design has different bowden lengths per gate, 0 = bowden length is the same -#variable_rotation_distances: 1 # 1 = If MMU design has dissimilar drive/BMG gears, thus rotation distance, 0 = One drive gear (e.g. Tradrack) -#require_bowden_move: 1 # 1 = If MMU design has bowden move that is included in load/unload, 0 = zero length bowden (skip bowden move) -#filament_always_gripped: 1 # 1 = Filament is always trapped by MMU (most type-B designs), 0 = MMU can release filament -#has_bypass: {has_bypass} # 1 = Integrated bypass gate available, 0 = No integrated bypass; Bypassing only possible via PTFE - -homing_extruder: 1 # CAUTION: Normally this should be 1. 0 will disable the homing extruder capability - -# Uncomment to change the display name in UI's. Defaults to the vendor name -#display_name: My Precious - -# Fullname of environment sensor for MMU filament storage (displays temp and humidity in UI). Leave empty if sensor is not fitted -# Polls for "temperature" and "humidity" -# E.g. If you have a section like this: [temperature_sensor MMU_enclosure] -# Set: environment_sensor: temperature_sensor MMU_enclosure -environment_sensor: temperature_sensor MMU_enviroment_kms - -# Full name of filament heater object. Leave empty if heater is not fitted -# E.g. If you have a section like this: [heater_generic MMU_heater] -# Set: filament_heater: heater_generic MMU_heater -filament_heater: heater_generic MMU_heater_kms - -# PER-GATE heaters and environment sensors. Some modular MMU designs have per-gate control (e.g. EMU). For this type of MMU, leave -# 'environment_sensor' and 'filament_heater' empty and define a list of heaters and sensors here. Note the length of the list must -# match the number of gates. Leave this settings empty if defining a single sensor/heater. -environment_sensors: # Comma separated list of full environment sensor object names -filament_heaters: # Comma separated list of full heater object names -max_concurrent_heaters: 1 # Limit the number of simultaneously active heaters (power/PSU protection) - - -# FILAMENT DRIVE GEAR STEPPER(S) -------------------------------------------------------------------------------------- -# ██████╗ ███████╗ █████╗ ██████╗ -# ██╔════╝ ██╔════╝██╔══██╗██╔══██╗ -# ██║ ███╗█████╗ ███████║██████╔╝ -# ██║ ██║██╔══╝ ██╔══██║██╔══██╗ -# ╚██████╔╝███████╗██║ ██║██║ ██║ -# ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ -# Note that 'toolhead' & 'mmu_gear' endstops will automatically be added if a toolhead sensor or gate sensor is defined -# -# The default values are tested with the ERCF BOM NEMA14 motor. Please adapt these values to the motor you are using -# Example : for NEMA17 motors, you'll usually use higher current -# -[tmc2209 stepper_mmu_gear] -uart_pin: mmu:PB4 -run_current: 0.7 -hold_current: 0.1 # Recommend to be small if not using "touch" or move (TMC stallguard) -interpolate: True -sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 -stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed -# -# Uncomment two lines below if you have TMC and want the ability to use filament "touch" homing with gear stepper -#diag_pin: mmu:PA3 # Set to MCU pin connected to TMC DIAG pin for gear stepper -#driver_SGTHRS: 60 # 255 is most sensitive value, 0 is least sensitive - -[stepper_mmu_gear] -step_pin: mmu:PB5 -dir_pin: mmu:PB6 -enable_pin: !mmu:PB3 -rotation_distance: 22.96 # Bondtech 5mm Drive Gears. Overridden by 'mmu_gear_rotation_distance' in mmu_vars.cfg -gear_ratio: 50:17 -microsteps: 16 # Recommend 16. Increase only if you "step compress" issues when syncing -full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree -# -# Uncomment the two lines below to enable filament "touch" homing option with gear motor -#extra_endstop_pins: tmc2209_stepper_mmu_gear:virtual_endstop -#extra_endstop_names: mmu_gear_touch - -# ADDITIONAL FILAMENT DRIVE GEAR STEPPERS FOR TYPE-B MMU's ------------------------------------------------------------- -# Note that common parameters are inherited from base stepper_mmu_gear, but can be uniquely specified here too -# -# Filament Drive Gear_1 -------------------------- -[tmc2209 stepper_mmu_gear_1] -uart_pin: mmu:PB8 - -[stepper_mmu_gear_1] -step_pin: mmu:PB9 -dir_pin: !mmu:PC10 -enable_pin: !mmu:PB7 -#diag_pin: mmu:PA4 - -# Filament Drive Gear_2 -------------------------- -[tmc2209 stepper_mmu_gear_2] -uart_pin: mmu:PD3 - -[stepper_mmu_gear_2] -step_pin: mmu:PD4 -dir_pin: mmu:PD5 -enable_pin: !mmu:PD6 -#diag_pin: mmu:PB9 - -# Filament Drive Gear_3 -------------------------- -[tmc2209 stepper_mmu_gear_3] -uart_pin: mmu:PC9 - -[stepper_mmu_gear_3] -step_pin: mmu:PD0 -dir_pin: !mmu:PD1 -enable_pin: !mmu:PD2 -#diag_pin: mmu:PB8 - - -# SERVOS --------------------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ ███████╗ -# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗██╔════╝ -# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║███████╗ -# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║╚════██║ -# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝███████║ -# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ -# Basic servo PWM setup. If these values are changed then the angles defined for different positions will also change -# -# OPTIONAL GANTRY SERVO FOR TOOLHEAD FILAMENT CUTTER ------------------------------------------------------------------ -# -# (uncomment this section if you have a gantry servo for toolhead cutter pin) -#[mmu_servo mmu_gantry_servo] -#pin: -#maximum_servo_angle:180 -#minimum_pulse_width: 0.00075 -#maximum_pulse_width: 0.00225 -#initial_angle: 180 - - -# FILAMENT SENSORS ----------------------------------------------------------------------------------------------------- -# ███████╗███████╗███╗ ██╗███████╗ ██████╗ ██████╗ ███████╗ -# ██╔════╝██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ -# ███████╗█████╗ ██╔██╗ ██║███████╗██║ ██║██████╔╝███████╗ -# ╚════██║██╔══╝ ██║╚██╗██║╚════██║██║ ██║██╔══██╗╚════██║ -# ███████║███████╗██║ ╚████║███████║╚██████╔╝██║ ██║███████║ -# ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ -# Define the pins for optional sensors in the filament path. All but the pre-gate sensors will be automatically setup as -# both endstops (for homing) and sensors for visibility purposes. -# -# 'pre_gate_switch_pin_X' .. 'mmu_pre_gate_X' sensor detects filament at entry to MMU. X=gate number (0..N) -# 'gate_switch_pin' .. 'mmu_gate' shared sensor detects filament past the gate of the MMU -# or -# 'post_gear_switch_pin_X' .. 'mmu_gear_X' post gear sensor for each filament -# 'extruder_switch_pin' .. 'extruder' sensor detects filament just before the extruder entry -# 'toolhead_switch_pin' .. 'toolhead' sensor detects filament after extruder entry -# -# Sync motor feedback will typically have a tension switch (most important for syncing) or both tension and compression. -# Note that compression switch is useful for use as a endstop to detect hitting the extruder entrance -# 'sync_feedback_tension_pin' .. pin for switch activated when filament is under tension -# 'sync_feedback_compression_pin' .. pin for switch activated when filament is under compression -# -# Configuration is flexible: Simply define pins for any sensor you want to enable, if pin is not set (or the alias is empty) -# it will be ignored. You can also just comment out what you are not using. -# -[mmu_sensors] -pre_gate_switch_pin_0: mmu:PA0 -pre_gate_switch_pin_1: mmu:PA1 -pre_gate_switch_pin_2: mmu:PA2 -pre_gate_switch_pin_3: mmu:PA3 - -post_gear_switch_pin_0: mmu:PA5 -post_gear_switch_pin_1: mmu:PA6 -post_gear_switch_pin_2: mmu:PA7 -post_gear_switch_pin_3: mmu:PC4 - -# These sensors can be replicated in a multi-mmu, type-B setup (see num_gates comment). -# If so, then use a comma separated list of per-unit pins instead of single pin -gate_switch_pin: mmu:PC0 - -sync_feedback_tension_pin: !buffer:PA1 -sync_feedback_compression_pin: !buffer:PA0 - -# These sensors are on the toolhead and often controlled by the main printer mcu -#extruder_switch_pin: PG11 -#toolhead_switch_pin: PG13 - - -# ENCODER ------------------------------------------------------------------------------------------------------------- -# ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ -# ██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗ -# █████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗ ██████╔╝ -# ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗ -# ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗██║ ██║ -# ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ -# Encoder measures distance, monitors for runout and clogging and constantly calculates % flow rate -# Note that the encoder_resolution set here is purely a default to get started. It will be correcly set after calibration -# with the value stored in mmu_vars.cfg -# -# The encoder resolution will be calibrated but it needs a default approximation -# If BMG gear based: -# resolution = bmg_circumfrance / (2 * teeth) -# 24 / (2 * 17) = 0.7059 for TRCT5000 based sensor -# 24 / (2 * 12) = 1.0 for Binky with 12 tooth disc -# -[mmu_encoder mmu_encoder] -encoder_pin: mmu:PC5 -encoder_resolution: 0.967 # This is just a starter value. Overriden by calibrated 'mmu_encoder_resolution' in mmm_vars.cfg -desired_headroom: 5.0 # The clog/runout headroom that MMU attempts to maintain (closest point to triggering runout) -average_samples: 4 # The "damping" effect of last measurement (higher value means slower automatic clog_length reduction) -flowrate_samples: 20 # How many "movements" of the extruder to measure average flowrate over - - -# ESPOOLER (OPTIONAL) ------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ -# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ -# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ -# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ -# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ -# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ -# -# An espooler controls DC motors (typically N20) that are able to rewind a filament spool and optionally provide -# forward assist to overcome spooler rotation friction. This should define pins for each of the gates on your mmu -# starting with '_0'. -# An empty pin can be deleted, commented or simply left blank. If you mcu has a separate "enable" pin -# -[mmu_espooler mmu_espooler] -#pwm: 1 # 1=PWM control (typical), 0=digital on/off control -#hardware_pwm: 0 # See klipper doc -#cycle_time: 0.100 # See klipper doc -#scale: 1 # Scales the PWM output range -#value: 0 # See klipper doc -#shutdown_value: 0 # See klipper doc - -assist_motor_pin_0: mmu:PA8 # PWM (or digital) pin for forward motor movement -respool_motor_pin_0: mmu:PB15 # PWM (or digital) pin for rewind/respool movement -#enable_motor_pin_0: # Digital output to enable motors -#assist_trigger_pin_0: # Trigger pin for sensing need to assist during print - -assist_motor_pin_1: mmu:PB13 -respool_motor_pin_1: mmu:PB14 -#enable_motor_pin_1: -#assist_trigger_pin_1: - -assist_motor_pin_2: mmu:PA9 -respool_motor_pin_2: mmu:PC6 -#enable_motor_pin_2: -#assist_trigger_pin_2: - -assist_motor_pin_3: mmu:PD8 -respool_motor_pin_3: mmu:PC7 -#enable_motor_pin_3: -#assist_trigger_pin_3: - - -# LED SUPPORT (OPTIONAL) ----------------------------------------------------------------------------------------------- -# ██╗ ███████╗██████╗ ███████╗ -# ██║ ██╔════╝██╔══██╗██╔════╝ -# ██║ █████╗ ██║ ██║███████╗ -# ██║ ██╔══╝ ██║ ██║╚════██║ -# ███████╗███████╗██████╔╝███████║ -# ╚══════╝╚══════╝╚═════╝ ╚══════╝ -# -# Define mmu leds, both the "neopixel" config and [mmu_led] to define their purpose -# -[neopixel mmu_leds] -pin: mmu:PC13 -chain_count: 4 -color_order: GRBW - -# MMU LED EFFECT SEGMENTS ---------------------------------------------------------------------------------------------- -# Define neopixel LEDs for your MMU. The chain_count must be large enough for your desired ranges: -# exit .. this set of LEDs, one for every gate, usually would be mounted at the exit point of the gate -# entry .. this set of LEDs, one for every gate, could be mounted at the entry point of filament into this MMU/buffer -# status .. these LED. represents the status of the MMU (and selected filament). More than one status LED is possible -# logo .. these LEDs don't change during operation and are designed lighting a logo. Multiple logo LEDs are possible -# -# Note that all sets are optional. You can opt to just have the 'exit' set for example. The advantage to having -# both entry and exit LEDs is, for example, so that 'entry' can display gate status while 'exit' displays the color -# -# The animation effects requires the installation of Julian Schill's awesome LED effect module otherwise the LEDs -# will be static: -# https://github.com/julianschill/klipper-led_effect -# -# LED's are indexed in the chain from 1..N. Thus to set up LED's on 'exit' and a single 'status' LED on a 4 gate MMU: -# -# exit_leds: neopixel:mmu_leds (1,2,3,4) -# status_leds: neopixel:mmu_leds (5) -# -# In this example no 'entry' set is configured. Note that constructs like "mmu_leds (1-3,4)" are also valid -# -# The range is completely flexible and can be comprised of different led strips, individual LEDs, or combinations of -# both on different pins. In addition, the ordering is flexible based on your wiring, thus (1-4) and (4-1) both -# represent the same LED range but mapped to increasing or decreasing gates respectively. E.g if you have two Box -# Turtle MMUs, one with a chain of LEDs wired in reverse order and another with individual LEDs, to define 8 exit LEDs: -# -# exit_leds: neopixel:bt_1 (4-1) -# neopixel:bt_2a -# neopixel:bt_2b -# neopixel:bt_2c -# neopixel:bt_2d -# -# Note the use of separate lines for each part of the definition, -# -# ADVANCED: Happy Hare provides a convenience wrapper [mmu_led_effect] that not only creates an effect on each of the -# [mmu_leds] specified segments as a whole but also each individual LED for atomic control. See mmu_leds.cfg for examples -# -# (comment out this whole section if you don't have/want leds; uncomment/edit LEDs fitted on your MMU) -# -[mmu_leds unit0] -exit_leds: neopixel:mmu_leds (1-4) -frame_rate: 24 - -# Default effects for LED segments when not providing action status -# off - LED's off -# on - LED's white -# gate_status - indicate gate availability / status (printer.mmu.gate_status) -# filament_color - display filament color defined in gate map (printer.mmu.gate_color_rgb) -# slicer_color - display slicer defined set color for each gate (printer.mmu.slicer_color_rgb) -# (r,g,b) - display static r,g,b color e.g. "0,0,0.3" for dim blue -# _effect_ - display the named led effect -# -enabled: True # True = LEDs are enabled at startup (MMU_LED can control), False = Disabled -animation: True # True = Use led-animation-effects, False = Static LEDs -exit_effect: gate_status # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -entry_effect: filament_color # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -status_effect: filament_color # on|off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -logo_effect: (0, 0, 0.3) # off |r,g,b|_effect_ -white_light: (1, 1, 1) # RGB color for static white light -black_light: (.01, 0, .02) # RGB color used to represent "black" (filament) -empty_light: (0, 0, 0) # RGB color used to represent empty gate - -# Default effects (animation: True) / static rbg (animation False) to apply to actions -# effect_name, (r,b,g) -# -# IMPORTANT: Effects must be from [mmu_led_effects] set defined in mmu_hardware.cfg -# -effect_loading: mmu_blue_clockwise_slow, (0, 0, 0.4) -effect_loading_extruder: mmu_blue_clockwise_fast, (0, 0, 1) -effect_unloading: mmu_blue_anticlock_slow, (0, 0, 0.4) -effect_unloading_extruder: mmu_blue_anticlock_fast, (0, 0, 1) -effect_heating: mmu_breathing_red, (0.3, 0, 0) -effect_selecting: mmu_white_fast, (0.2, 0.2, 0.2) -effect_checking: mmu_white_fast, (0.8, 0.8, 0.8) -effect_initialized: mmu_rainbow, (0.5, 0.2, 0) -effect_error: mmu_strobe, (1, 0, 0) -effect_complete: mmu_sparkle, (0.3, 0.3, 0.3) -effect_gate_selected: mmu_static_blue, (0, 0, 1) -effect_gate_available: mmu_static_green, (0, 0.5, 0) -effect_gate_available_sel: mmu_ready_green, (0, 0.75, 0) -effect_gate_unknown: mmu_static_orange, (0.5, 0.2, 0) -effect_gate_unknown_sel: mmu_ready_orange, (0.75, 0.3, 0) -effect_gate_empty: mmu_static_black, (0, 0, 0) -effect_gate_empty_sel: mmu_ready_orange2, (0.1, 0.04, 0) - - -# ADDITIONAL HARDWARE ------------------------------------------------------------------------------------------------- -# ███╗ ███╗██╗███████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ███████╗ -# ████╗ ████║██║██╔════╝██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔════╝ -# ██╔████╔██║██║███████╗██║ ███████║███████║██████╔╝██║ ██║██║ █╗ ██║███████║██████╔╝█████╗ -# ██║╚██╔╝██║██║╚════██║██║ ██╔══██║██╔══██║██╔══██╗██║ ██║██║███╗██║██╔══██║██╔══██╗██╔══╝ -# ██║ ╚═╝ ██║██║███████║╚██████╗ ██║ ██║██║ ██║██║ ██║██████╔╝╚███╔███╔╝██║ ██║██║ ██║███████╗ -# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ -# -# Define any additional hardware for this MMU unit, e.g. heaters, chamber temperature probes, etc -# -[temperature_sensor MMU_enviroment_kms] -sensor_type: HTU21D -i2c_mcu: mmu -htu21d_report_time: 10 -i2c_software_scl_pin: mmu:PB10 -i2c_software_sda_pin: mmu:PB11 - -[heater_fan MMU_kms_box_left_fan] -pin: mmu:PC3 -shutdown_speed: 0 -heater: MMU_heater_kms - -[heater_fan MMU_kms_box_right_fan] -pin: mmu:PC1 -shutdown_speed: 0 -heater: MMU_heater_kms - -[heater_generic MMU_heater_kms] -gcode_id: mmu_heater_kms -heater_pin: mmu:PB1 -max_power:1.0 -sensor_type: EPCOS 100K B57560G104F -pullup_resistor: 2200 -sensor_pin: mmu:PA4 -control:watermark -#control: pid -#pid_Kp= -#pid_Ki= -#pid_Kd= -min_temp:0 -max_temp:70 - -[verify_heater MMU_heater_kms] -max_error: 300 -check_gain_time: 600 -hysteresis: 10 -heating_gain: 1 diff --git a/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg.vvd b/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg.vvd deleted file mode 100644 index dfda737aa42b..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_hardware.cfg.vvd +++ /dev/null @@ -1,401 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Happy Hare MMU hardware config file with config for BTT ViViD MCU board -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# Notes about setup of common external MCUs can be found here: -# https://github.com/moggieuk/Happy-Hare/blob/main/doc/mcu_notes.md -# -# Note about "touch" endstops: Happy Hare provides extremely flexible homing options using both single steppers or -# synced steppers. The "touch" option leverages stallguard and thus requires the appropriate 'diag_pin' and stallguard -# parameters set on the TMC driver section. If you have the diag_pin exposed, it is harmless to define this because -# they will only be used when explicitly needed and configured. -# -# Touch option for each stepper provides these benefits / possibilities (experimental): -# - on extruder stepper allows for the automatic detection of the nozzle! -# - on selector stepper allows for the automatic detection of filament stuck in the gate and subsequent recovery -# - on gear stepper allows for the automatic detection of the extruder entrance -# -# In summary, "touch" homing with your MMU is an advanced option that requires patience and careful tuning. Everything -# works with regular endstops and there are workaround options for certain homing points (like extruder entry) in -# the absence of any endstop. I'm really interested in creative setups. Ping me on Discord (moggieuk#6538) -# -# See 'mmu.cfg' for serial definition and pins aliases -# -# HOMING CAPABLE EXTRUDER (VERY ADVANCED) ----------------------------------------------------------------------------- -# With Happy Hare installed even the extruder can be homed. You will find the usual 'endstop' parameters can be added -# to your '[extruder]' section. Useless you have some clever load cell attached to your nozzle it only really makes -# sense to configure stallguard style "touch" homing. To do this add lines similar to this to your existing -# '[extruder]' definition in printer.cfg. -# -# [extruder] -# endstop_pin: tmc2209_extruder:virtual_endstop -# -# Also be sure to add the appropriate stallguard config to the TMC section, e.g. -# -# [tmc2209 extruder] -# diag_pin: E_DIAG # Set to MCU pin connected to TMC DIAG pin for extruder -# driver_SGTHRS: 100 # 255 is most sensitive value, 0 is least sensitive -# -# Happy Hare will take care of the rest and add a 'mmu_ext_touch' endstop automatically -# - - -# MMU MACHINE / TYPE --------------------------------------------------------------------------------------------------- -# ███╗ ███╗███╗ ███╗██╗ ██╗ ███╗ ███╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗███████╗ -# ████╗ ████║████╗ ████║██║ ██║ ████╗ ████║██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝ -# ██╔████╔██║██╔████╔██║██║ ██║ ██╔████╔██║███████║██║ ███████║██║██╔██╗ ██║█████╗ -# ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ ██║╚██╔╝██║██╔══██║██║ ██╔══██║██║██║╚██╗██║██╔══╝ -# ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║██║██║ ╚████║███████╗ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ -[mmu_machine] - -# Number of selectable gate on (each) MMU. Generally this is a single number, but with multi-mmu (type-B) setups -# it can be a comma separated list of the number of gates per unit. -# E.g. 'num_gates: 4,4,2' for a 2xBox Turtle and 1xNight Owl multiplexed setup -# -num_gates: 4 - -# MMU Vendor & Version is used to automatically configure some parameters and validate configuration -# If custom set to "Other" and uncomment the additional parameters below -# -mmu_vendor: VVD # MMU family -mmu_version: 1.0 # MMU hardware version number (add mod suffix documented above) - -# The following attributes are set internally from vendor/version above. Only uncomment to customize the vendor -# default or for custom ("Other") designs -# -#selector_type: IndexedSelector # E.g. LinearSelector (type-A), VirtualSelector (type-B), MacroSelector, RotarySelector, ... -#variable_bowden_lengths: 0 # 1 = If MMU design has different bowden lengths per gate, 0 = bowden length is the same -#variable_rotation_distances: 1 # 1 = If MMU design has dissimilar drive/BMG gears, thus rotation distance, 0 = One drive gear (e.g. Tradrack) -#require_bowden_move: 1 # 1 = If MMU design has bowden move that is included in load/unload, 0 = zero length bowden (skip bowden move) -#filament_always_gripped: 1 # 1 = Filament is always trapped by MMU (most type-B designs), 0 = MMU can release filament -#has_bypass: {has_bypass} # 1 = Integrated bypass gate available, 0 = No integrated bypass; Bypassing only possible via PTFE - -homing_extruder: 1 # CAUTION: Normally this should be 1. 0 will disable the homing extruder capability - -# Uncomment to change the display name in UI's. Defaults to the vendor name -#display_name: My Precious - -# Fullname of environment sensor for MMU filament storage (displays temp and humidity in UI). Leave empty if sensor is not fitted -# Polls for "temperature" and "humidity" -# E.g. If you have a section like this: [temperature_sensor MMU_enclosure] -# Set: environment_sensor: temperature_sensor MMU_enclosure -environment_sensor:temperature_sensor ptc_ntc100k - -# Full name of filament heater object. Leave empty if heater is not fitted -# E.g. If you have a section like this: [heater_generic MMU_heater] -# Set: filament_heater: heater_generic MMU_heater -filament_heater: heater_generic MMU_heater_vivid - -# PER-GATE heaters and environment sensors. Some modular MMU designs have per-gate control (e.g. EMU). For this type of MMU, leave -# 'environment_sensor' and 'filament_heater' empty and define a list of heaters and sensors here. Note the length of the list must -# match the number of gates. Leave this settings empty if defining a single sensor/heater. -environment_sensors: # Comma separated list of full environment sensor object names -filament_heaters: # Comma separated list of full heater object names -max_concurrent_heaters: 1 # Limit the number of simultaneously active heaters (power/PSU protection) - - -# FILAMENT DRIVE GEAR STEPPER(S) -------------------------------------------------------------------------------------- -# ██████╗ ███████╗ █████╗ ██████╗ -# ██╔════╝ ██╔════╝██╔══██╗██╔══██╗ -# ██║ ███╗█████╗ ███████║██████╔╝ -# ██║ ██║██╔══╝ ██╔══██║██╔══██╗ -# ╚██████╔╝███████╗██║ ██║██║ ██║ -# ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ -# -# Note that 'toolhead' & 'mmu_gear' endstops will automatically be added if a toolhead sensor or gate sensor is defined -# - -[tmc2209 stepper_mmu_gear] -uart_pin: mmu:PB7 -run_current: 0.7 -hold_current: 0.1 # Recommend to be small if not using "touch" or move (TMC stallguard) -interpolate: True -sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 -stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed - -[stepper_mmu_gear] -step_pin: mmu:PB6 -dir_pin: mmu:PB5 -enable_pin: !mmu:PB8 -rotation_distance: 8.0 # This is highly variable -- tune each gate with MMU_CALIBRATE_GEAR -gear_ratio: 1:1 -microsteps: 16 -full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree - - -# SELECTOR STEPPER ---------------------------------------------------------------------------------------------------- -# ███████╗███████╗██╗ ███████╗ ██████╗████████╗ ██████╗ ██████╗ -# ██╔════╝██╔════╝██║ ██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ -# ███████╗█████╗ ██║ █████╗ ██║ ██║ ██║ ██║██████╔╝ -# ╚════██║██╔══╝ ██║ ██╔══╝ ██║ ██║ ██║ ██║██╔══██╗ -# ███████║███████╗███████╗███████╗╚██████╗ ██║ ╚██████╔╝██║ ██║ -# ╚══════╝╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ -# -# Consult doc if you want to setup selector for "touch" homing instead or physical endstop -# -[tmc2209 stepper_mmu_selector] -uart_pin: mmu:PB3 -run_current: 0.8 -hold_current: 0.4 # Recommend to be small if not using "touch" or move (TMC stallguard) -interpolate: True -sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 -stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed - -[stepper_mmu_selector] -step_pin: mmu:PD3 -dir_pin: mmu:PD2 -enable_pin: !mmu:PB4 -rotation_distance: 360 -gear_ratio: 25:10 -microsteps: 16 # Don't need high fidelity -full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree -extra_endstop_pins: !mmu:PD0, !mmu:PA15, !mmu:PD1, !mmu:PC13 -extra_endstop_names: unit0_gate0, unit0_gate1, unit0_gate2, unit0_gate3 - - -# SERVOS --------------------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ ███████╗ -# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗██╔════╝ -# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║███████╗ -# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║╚════██║ -# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝███████║ -# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ -# Basic servo PWM setup. If these values are changed then the angles defined for different positions will also change -# -# OPTIONAL GANTRY SERVO FOR TOOLHEAD FILAMENT CUTTER ------------------------------------------------------------------ -# -# (uncomment this section if you have a gantry servo for toolhead cutter pin) -#[mmu_servo mmu_gantry_servo] -#pin: -#maximum_servo_angle:180 -#minimum_pulse_width: 0.00075 -#maximum_pulse_width: 0.00225 -#initial_angle: 180 - - -# FILAMENT SENSORS ----------------------------------------------------------------------------------------------------- -# ███████╗███████╗███╗ ██╗███████╗ ██████╗ ██████╗ ███████╗ -# ██╔════╝██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ -# ███████╗█████╗ ██╔██╗ ██║███████╗██║ ██║██████╔╝███████╗ -# ╚════██║██╔══╝ ██║╚██╗██║╚════██║██║ ██║██╔══██╗╚════██║ -# ███████║███████╗██║ ╚████║███████║╚██████╔╝██║ ██║███████║ -# ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ -# Define the pins for optional sensors in the filament path. All but the pre-gate sensors will be automatically setup as -# both endstops (for homing) and sensors for visibility purposes. -# -# 'pre_gate_switch_pin_X' .. 'mmu_pre_gate_X' sensor detects filament at entry to MMU. X=gate number (0..N) -# 'gate_switch_pin' .. 'mmu_gate' shared sensor detects filament past the gate of the MMU -# or -# 'post_gear_switch_pin_X' .. 'mmu_gear_X' post gear sensor for each filament -# 'extruder_switch_pin' .. 'extruder' sensor detects filament just before the extruder entry -# 'toolhead_switch_pin' .. 'toolhead' sensor detects filament after extruder entry -# -# Sync motor feedback will typically have a tension switch (most important for syncing) or both tension and compression. -# Note that compression switch is useful for use as a endstop to detect hitting the extruder entrance -# 'sync_feedback_tension_pin' .. pin for switch activated when filament is under tension -# 'sync_feedback_compression_pin' .. pin for switch activated when filament is under compression -# -# Configuration is flexible: Simply define pins for any sensor you want to enable, if pin is not set (or the alias is empty) -# it will be ignored. You can also just comment out what you are not using. -# -[mmu_sensors] -pre_gate_switch_pin_0: mmu:PA0 -pre_gate_switch_pin_1: mmu:PA1 -pre_gate_switch_pin_2: mmu:PA2 -pre_gate_switch_pin_3: mmu:PA3 - -post_gear_switch_pin_0: buffer:PA0 -#post_gear_analog_range_0: 0, 500 # Delete if using updated digital sensor -post_gear_switch_pin_1: buffer:PA1 -#post_gear_analog_range_1: 0, 500 # Delete if using updated digital sensor -post_gear_switch_pin_2: buffer:PA2 -#post_gear_analog_range_2: 0, 500 # Delete if using updated digital sensor -post_gear_switch_pin_3: buffer:PA3 -#post_gear_analog_range_3: 0, 500 # Delete if using updated digital sensor - -sync_feedback_tension_pin: buffer:PA4 -sync_feedback_compression_pin: buffer:PA5 - -# These sensors are on the toolhead and often controlled by the main printer mcu -#extruder_switch_pin: PG11 -#toolhead_switch_pin: PG13 - - -# LED SUPPORT (OPTIONAL) ----------------------------------------------------------------------------------------------- -# ██╗ ███████╗██████╗ ███████╗ -# ██║ ██╔════╝██╔══██╗██╔════╝ -# ██║ █████╗ ██║ ██║███████╗ -# ██║ ██╔══╝ ██║ ██║╚════██║ -# ███████╗███████╗██████╔╝███████║ -# ╚══════╝╚══════╝╚═════╝ ╚══════╝ -# -# Define mmu leds, both the "neopixel" config and [mmu_led] to define their purpose -# -[neopixel mmu_leds_1] -pin: mmu:PC6 -chain_count: 14 -color_order: GRB - -[neopixel mmu_leds_2] -pin: mmu:PC7 -chain_count: 14 -color_order: GRB - -# MMU LED EFFECT SEGMENTS ---------------------------------------------------------------------------------------------- -# Define neopixel LEDs for your MMU. The chain_count must be large enough for your desired ranges: -# exit .. this set of LEDs, one for every gate, usually would be mounted at the exit point of the gate -# entry .. this set of LEDs, one for every gate, could be mounted at the entry point of filament into this MMU/buffer -# status .. these LED. represents the status of the MMU (and selected filament). More than one status LED is possible -# logo .. these LEDs don't change during operation and are designed lighting a logo. Multiple logo LEDs are possible -# -# Note that all sets are optional. You can opt to just have the 'exit' set for example. The advantage to having -# both entry and exit LEDs is, for example, so that 'entry' can display gate status while 'exit' displays the color -# -# The animation effects requires the installation of Julian Schill's awesome LED effect module otherwise the LEDs -# will be static: -# https://github.com/julianschill/klipper-led_effect -# -# LED's are indexed in the chain from 1..N. Thus to set up LED's on 'exit' and a single 'status' LED on a 4 gate MMU: -# -# exit_leds: neopixel:mmu_leds (1,2,3,4) -# status_leds: neopixel:mmu_leds (5) -# -# In this example no 'entry' set is configured. Note that constructs like "mmu_leds (1-3,4)" are also valid -# -# The range is completely flexible and can be comprised of different led strips, individual LEDs, or combinations of -# both on different pins. In addition, the ordering is flexible based on your wiring, thus (1-4) and (4-1) both -# represent the same LED range but mapped to increasing or decreasing gates respectively. E.g if you have two Box -# Turtle MMUs, one with a chain of LEDs wired in reverse order and another with individual LEDs, to define 8 exit LEDs: -# -# exit_leds: neopixel:bt_1 (4-1) -# neopixel:bt_2a -# neopixel:bt_2b -# neopixel:bt_2c -# neopixel:bt_2d -# -# Note the use of separate lines for each part of the definition, -# -# ADVANCED: Happy Hare provides a convenience wrapper [mmu_led_effect] that not only creates an effect on each of the -# [mmu_leds] specified segments as a whole but also each individual LED for atomic control. See mmu_leds.cfg for examples -# -# (comment out this whole section if you don't have/want leds; uncomment/edit LEDs fitted on your MMU) -# -[mmu_leds unit0] -exit_leds: neopixel:mmu_leds_1 (1-14) - neopixel:mmu_leds_2 (1-14) -frame_rate: 24 - -# Default effects for LED segments when not providing action status -# off - LED's off -# on - LED's white -# gate_status - indicate gate availability / status (printer.mmu.gate_status) -# filament_color - display filament color defined in gate map (printer.mmu.gate_color_rgb) -# slicer_color - display slicer defined set color for each gate (printer.mmu.slicer_color_rgb) -# (r,g,b) - display static r,g,b color e.g. "0,0,0.3" for dim blue -# _effect_ - display the named led effect -# -enabled: True # True = LEDs are enabled at startup (MMU_LED can control), False = Disabled -animation: True # True = Use led-animation-effects, False = Static LEDs -exit_effect: gate_status # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -entry_effect: filament_color # off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -status_effect: filament_color # on|off|gate_status|filament_color|slicer_color|r,g,b|_effect_ -logo_effect: (0, 0, 0.3) # off |r,g,b|_effect_ -white_light: (1, 1, 1) # RGB color for static white light -black_light: (.01, 0, .02) # RGB color used to represent "black" (filament) -empty_light: (0, 0, 0) # RGB color used to represent empty gate - -# Default effects (animation: True) / static rbg (animation False) to apply to actions -# effect_name, (r,b,g) -# -# IMPORTANT: Effects must be from [mmu_led_effects] set defined in mmu_hardware.cfg -# -effect_loading: mmu_blue_clockwise_slow, (0, 0, 0.4) -effect_loading_extruder: mmu_blue_clockwise_fast, (0, 0, 1) -effect_unloading: mmu_blue_anticlock_slow, (0, 0, 0.4) -effect_unloading_extruder: mmu_blue_anticlock_fast, (0, 0, 1) -effect_heating: mmu_breathing_red, (0.3, 0, 0) -effect_selecting: mmu_white_fast, (0.2, 0.2, 0.2) -effect_checking: mmu_white_fast, (0.8, 0.8, 0.8) -effect_initialized: mmu_rainbow, (0.5, 0.2, 0) -effect_error: mmu_strobe, (1, 0, 0) -effect_complete: mmu_sparkle, (0.3, 0.3, 0.3) -effect_gate_selected: mmu_static_blue, (0, 0, 1) -effect_gate_available: mmu_static_green, (0, 0.5, 0) -effect_gate_available_sel: mmu_ready_green, (0, 0.75, 0) -effect_gate_unknown: mmu_static_orange, (0.5, 0.2, 0) -effect_gate_unknown_sel: mmu_ready_orange, (0.75, 0.3, 0) -effect_gate_empty: mmu_static_black, (0, 0, 0) -effect_gate_empty_sel: mmu_ready_orange2, (0.1, 0.04, 0) - - -# ADDITIONAL HARDWARE ------------------------------------------------------------------------------------------------- -# ███╗ ███╗██╗███████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ███████╗ -# ████╗ ████║██║██╔════╝██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔════╝ -# ██╔████╔██║██║███████╗██║ ███████║███████║██████╔╝██║ ██║██║ █╗ ██║███████║██████╔╝█████╗ -# ██║╚██╔╝██║██║╚════██║██║ ██╔══██║██╔══██║██╔══██╗██║ ██║██║███╗██║██╔══██║██╔══██╗██╔══╝ -# ██║ ╚═╝ ██║██║███████║╚██████╗ ██║ ██║██║ ██║██║ ██║██████╔╝╚███╔███╔╝██║ ██║██║ ██║███████╗ -# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ -# -# Define any additional hardware for this MMU unit, e.g. heaters, chamber temperature probes, etc -# -[temperature_sensor MMU_vivid_left] -sensor_type: AHT3X # Use an AHT10 and set 'update_aht10_commands: 1' in mmu_parameters.cfg if the AHT3X sensor_type isn't supported (older Klipper versions) -i2c_mcu: mmu -i2c_software_scl_pin: mmu:PA9 -i2c_software_sda_pin: mmu:PA10 -aht10_report_time: 10 - -[temperature_sensor MMU_vivid_right] -sensor_type: AHT3X # Use an AHT10 and set 'update_aht10_commands: 1' in mmu_parameters.cfg if the AHT3X sensor_type isn't supported (older Klipper versions) -i2c_mcu: mmu -i2c_software_scl_pin: mmu:PA7 -i2c_software_sda_pin: mmu:PA6 -aht10_report_time: 10 - -[heater_generic MMU_heater_vivid] -gcode_id: mmu_heater_vivid -heater_pin: mmu:PC14 -sensor_type: temperature_combined -sensor_list: temperature_sensor MMU_vivid_left, temperature_sensor MMU_vivid_right -combination_method: mean -maximum_deviation: 100 -control: pid -pid_Kp=51.604 -pid_Ki=1.121 -pid_Kd=593.934 -min_temp: -100 -max_temp: 70 - -[verify_heater MMU_heater_vivid] -max_error: 300 -check_gain_time: 600 -hysteresis: 10 -heating_gain: 1 - -[heater_fan MMU_vivid_box_fan] -pin: mmu:PC15 -shutdown_speed: 0 -heater: MMU_heater_vivid - -# Electronics cooling fan -[controller_fan my_controller_fan] -pin: mmu:PA14 -stepper: stepper_mmu_selector, stepper_mmu_gear - -[temperature_sensor ptc_ntc100k] -sensor_pin: mmu:PA4 -sensor_type: Generic 3950 -pullup_resistor: 2200 diff --git a/klippy/extras/Happy-Hare/config/base/mmu_heater_vent.cfg b/klippy/extras/Happy-Hare/config/base/mmu_heater_vent.cfg deleted file mode 100644 index 2de624b1920e..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_heater_vent.cfg +++ /dev/null @@ -1,56 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Outline (very simplistic reference) macro that can could be called from 'heater_vent_macro` -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# Copy this macro to another file of your own, rename the macro names and set 'heater_vent_macro' -# and 'heater_vent_interval' accordingly. Then complete the logic for your MMU to control venting. -# As it stands it will simple log the intent to the console and mmu.log -# NOTE: this can be called during a print so don't add delays or M400 constructs -# (read the wiki: Heater & Environment Control pages) -# -# heater_vent_macro: _MMU_VENT -# heater_vent_interval: -# -[gcode_macro _MMU_VENT] -description: Simple reference MMU enclosure venting control - -gcode: - # GATES param can generally be ignored unless MMU design has individual spool enclosures that can operate independently - {% set gates = (params.GATES | default('')).split(',') | map('trim') | list %} - - {% if gates == [''] %} - - MMU_LOG MSG="Opening MMU vent..." - # Add logic to operate servo to open vent here, perhaps also turn on/up extraction fan - - {% else %} - - MMU_LOG MSG="Opening MMU vent to dry filaments in gates: {", ".join(gates)}..." - {% for gate in range(gate) %} - # Open vent servo_{gate} - {% endfor %} - - {% endif %} - - - # Close the vent after 10 seconds - UPDATE_DELAYED_GCODE ID=_MMU_VENT_CLOSE DURATION=10 - - -[delayed_gcode _MMU_VENT_CLOSE] -gcode: - MMU_LOG MSG="Closing MMU vent..." - # Add logic to operate servo to close vent here, perhaps also turn off/down extraction fan diff --git a/klippy/extras/Happy-Hare/config/base/mmu_leds.cfg b/klippy/extras/Happy-Hare/config/base/mmu_leds.cfg deleted file mode 100644 index d85b9b90ee4e..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_leds.cfg +++ /dev/null @@ -1,89 +0,0 @@ -# -# NOTE: This is a temporary pre-inclusion of Happy Hare v4 functionality for the v3.4.0 release -# - -# LED EFFECTS --------------------------------------------------------------------------------------------------------- -# ██╗ ███████╗██████╗ ███████╗███████╗███████╗███████╗ ██████╗████████╗███████╗ -# ██║ ██╔════╝██╔══██╗ ██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ -# ██║ █████╗ ██║ ██║ █████╗ █████╗ █████╗ █████╗ ██║ ██║ ███████╗ -# ██║ ██╔══╝ ██║ ██║ ██╔══╝ ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║ ╚════██║ -# ███████╗███████╗██████╔╝ ███████╗██║ ██║ ███████╗╚██████╗ ██║ ███████║ -# ╚══════╝╚══════╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚══════╝ -# -# These are the default effects that may be assigned to actions in the [mmu_leds] section. Note that 'define_on' -# is optional and if sepcified can be used to restrict effect creation to the desired segments -# -[mmu_led_effect mmu_breathing_red] -layers: breathing 4 0 top (1,0,0) - -[mmu_led_effect mmu_white_slow] -define_on: exit, status -layers: breathing 1.0 0 top (0.8,0.8,0.8) - -[mmu_led_effect mmu_white_fast] -define_on: exit, status -layers: breathing 0.6 0 top (0.2,0.2,0.2) - -[mmu_led_effect mmu_blue_clockwise_slow] -define_on: gates, status -layers: chase .5 .5 top (0,0,1), (0,0,.4) - -[mmu_led_effect mmu_blue_clockwise_fast] -define_on: gates, status -layers: chase 1 .5 top (0,0,1), (0,0,.4) - -[mmu_led_effect mmu_blue_anticlock_slow] -define_on: gates, status -layers: chase -.5 .5 top (0,0,1), (0,0,.4) - -[mmu_led_effect mmu_blue_anticlock_fast] -define_on: gates, status -layers: chase -1 .5 top (0,0,1), (0,0,.4) - -[mmu_led_effect mmu_strobe] -layers: strobe 1 1.5 add (1,1,1) - breathing 2 0 difference (0.95,0,0) - static 0 0 top (1,0,0) - -[mmu_led_effect mmu_static_green] -define_on: gates -layers: static 0 0 top (0,0.5,0) - -[mmu_led_effect mmu_ready_green] -define_on: gates -layers: breathing 2 0 subtract (0,0.35,0) - static 0 0 top (0,0.5,0) - -[mmu_led_effect mmu_static_orange] -define_on: gates -layers: static 0 0 top (0.5,0.2,0) - -[mmu_led_effect mmu_ready_orange] -define_on: gates -layers: breathing 2 0 subtract (0.35,0.14,0) - static 0 0 top (0.5,0.2,0) - -[mmu_led_effect mmu_ready_orange2] -define_on: gates -layers: breathing 2 0 top (0.175,0.07,0) - -[mmu_led_effect mmu_static_blue] -define_on: gates -layers: static 0 0 top (0,0,1) - -[mmu_led_effect mmu_ready_blue] -define_on: gates -layers: breathing 2 0 add (0,0,0.5) - static 0 0 top (0,0,0) - -[mmu_led_effect mmu_static_black] -define_on: gates -layers: static 0 0 top (0,0,0) - -[mmu_led_effect mmu_rainbow] -define_on: entry, exit, status -layers: gradient 0.8 0.5 add (0.3, 0.0, 0.0), (0.0, 0.3, 0.0), (0.0, 0.0, 0.3) - -[mmu_led_effect mmu_sparkle] -define_on: exit -layers: twinkle 8 0.15 top (0.3,0.3,0.3), (0.4,0,0.25) diff --git a/klippy/extras/Happy-Hare/config/base/mmu_macro_vars.cfg b/klippy/extras/Happy-Hare/config/base/mmu_macro_vars.cfg deleted file mode 100644 index c515b7c51db9..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_macro_vars.cfg +++ /dev/null @@ -1,485 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Happy Hare supporting MACRO configuration -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# Supporting set of macros supplied with Happy Hare can be customized by editing the macro "variables" declared here. -# -# This configuration will automatically retained and upgraded between releases (a backup of previous config files will -# always be made for your reference). If you want to customize macros beyond what is possible through these variables -# it is highly recommended you copy the macro to a new name and change the callback macro name in 'mmu_parameters.cfg' -# That way the default macros can still be upgraded but your customization will be left intact -# - - -# PERSISTED STATE --------------------------------------------------------- -# Happy Hare stores configuration and state in the klipper variables file. -# Since klipper can only be a single 'save_variables' file, if you already -# have one you will need to merge the two and point this appropriately. -# -[save_variables] -filename: ~/printer_data/config/mmu/mmu_vars.cfg - - -# NECESSARY KLIPPER OVERRIDES --------------------------------------------- -# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ -# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ -# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ -# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ -# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -# These supplemental settings essentially disable klipper's built in -# extrusion limits and is necessary when using an MMU -[extruder] -max_extrude_only_distance: 200 -max_extrude_cross_section: 50 - -# For dialog prompts and progress in Mainsail. Requires Mainsail version >= v2.9.0 -[respond] - -# Other Happy Hare prerequisites. Harmless if already defined elsewhere in user config -[display_status] -[pause_resume] -[virtual_sdcard] -path: ~/printer_data/gcodes -#on_error_gcode: CANCEL_PRINT - - -# PRINT START/END --------------------------------------------------------- -# ██████╗ ██████╗ ██╗███╗ ██╗████████╗ ███████╗████████╗ █████╗ ██████╗ ████████╗ -# ██╔══██╗██╔══██╗██║████╗ ██║╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝ -# ██████╔╝██████╔╝██║██╔██╗ ██║ ██║ ███████╗ ██║ ███████║██████╔╝ ██║ -# ██╔═══╝ ██╔══██╗██║██║╚██╗██║ ██║ ╚════██║ ██║ ██╔══██║██╔══██╗ ██║ -# ██║ ██║ ██║██║██║ ╚████║ ██║ ███████║ ██║ ██║ ██║██║ ██║ ██║ -# ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ -# (base/mmu_software.cfg) -# -[gcode_macro _MMU_SOFTWARE_VARS] -description: Happy Hare optional configuration for print start/end checks -gcode: # Leave empty - -# These variables control the behavior of the MMU_START_SETUP and MMU_START_LOAD_INITIAL_TOOL macros -variable_user_pre_initialize_extension : '' ; Executed at start of MMU_START_SETUP. Commonly G28 to home -variable_home_mmu : False ; True/False, Whether to home mmu before print starts -variable_check_gates : True ; True/False, Whether to check filament is loaded in all gates used -variable_load_initial_tool : True ; True/False, Whether to automatically load initial tool -# -# Automapping strategy to apply slicer tool map to find matching MMU gate (will adjust tool-to-gate map). Options are: -# 'none' - don't automap (i.e. don't update tool-to-gate map) -# 'filament_name' - exactly match on case insensitive filament name -# 'material' - exactly match on material -# 'color' - exactly match on color (with same material) -# 'closest_color' - match to closest available filament color (with same material) -# 'spool_id' - exactly match on spool_id [FUTURE] -variable_automap_strategy : "none" ; none|filament_name|material|color|closest_color|spool_id - -# These variables control the behavior of the MMU_END macro -variable_user_print_end_extension : '' ; Executed at start of MMU_END. Good place to move off print -variable_unload_tool : True ; True/False, Whether to unload the tool at the end of the print -variable_reset_ttg : False ; True/False, Whether reset TTG map at end of print -variable_dump_stats : True ; True/False, Whether to display print stats at end of print - - -# STATE MACHINE CHANGES --------------------------------------------------- -# ███████╗████████╗ █████╗ ████████╗███████╗ ██████╗██╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ███████╗ -# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ ██╔════╝██║ ██║██╔══██╗████╗ ██║██╔════╝ ██╔════╝ -# ███████╗ ██║ ███████║ ██║ █████╗ ██║ ███████║███████║██╔██╗ ██║██║ ███╗█████╗ -# ╚════██║ ██║ ██╔══██║ ██║ ██╔══╝ ██║ ██╔══██║██╔══██║██║╚██╗██║██║ ██║██╔══╝ -# ███████║ ██║ ██║ ██║ ██║ ███████╗ ╚██████╗██║ ██║██║ ██║██║ ╚████║╚██████╔╝███████╗ -# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ -# (base/mmu_state.cfg) -# -[gcode_macro _MMU_STATE_VARS] -description: Happy Hare configuration for state change hooks -gcode: # Leave empty - -# You can extend functionality to all Happy Hare state change or event -# macros by adding a command (or call to your gcode macro). -# E.g for additional LED logic or consumption counters -variable_user_action_changed_extension : '' ; Executed after default logic with duplicate params -variable_user_print_state_changed_extension : '' ; Executed after default logic with duplicate params -variable_user_mmu_event_extension : '' ; Executed after default logic with duplicate params - -# Maintenance warning limits (consumption counters) -variable_servo_down_limit : 5000 ; Set to -1 for no limit / disable warning -variable_cutter_blade_limit : 3000 ; Set to -1 for no limit / disable warning - - -# SEQUENCE MACRO - PARKING MOVEMENT AND TOOLCHANGE CONTROL ---------------- -# ███╗ ███╗ ██████╗ ██╗ ██╗███████╗███╗ ███╗███████╗███╗ ██╗████████╗ -# ████╗ ████║██╔═══██╗██║ ██║██╔════╝████╗ ████║██╔════╝████╗ ██║╚══██╔══╝ -# ██╔████╔██║██║ ██║██║ ██║█████╗ ██╔████╔██║█████╗ ██╔██╗ ██║ ██║ -# ██║╚██╔╝██║██║ ██║╚██╗ ██╔╝██╔══╝ ██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║ -# ██║ ╚═╝ ██║╚██████╔╝ ╚████╔╝ ███████╗██║ ╚═╝ ██║███████╗██║ ╚████║ ██║ -# ╚═╝ ╚═╝ ╚═════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝ -# Configure carefully if you 'enable_park: True' -# (base/mmu_sequence.cfg) -# -[gcode_macro _MMU_SEQUENCE_VARS] -description: Happy Hare sequence macro configuration variables -gcode: # Leave empty - -# Parking and movement controls: -# Happy Hare defines 7 operations that may require parking. You can specify -# whether to park for each of those operations both during a print and -# standalone (not printing) with Happy Hare or when HH is disabled: -# -# enable_park_printing -# This is a list of the operations that should result in toolhead parking -# while in a print. There are really two main starting points from which -# you can customize. If using the slicer to form tips (and toolchange is -# over the wipetower) you don't want to park on "toolchange" but you would -# want to on "runout" which is a forced toolchange unknown by the slicer. -# Typically you would also want to park at least on pause, cancel and -# complete if not done elsewhere -# -# enabled_park_standalone -# List of the operations that should result in toolhead parking when not -# printing, for example, just manipulating the MMU manually or via -# Klipperscreen. Really it is up to you to choose based on personal -# workflow preferences but this defaults to just 'pause,cancel' -# (i.e. disabled for toolchange) -# -# enabled_park_disabled -# List of the operations that should result in toolhead parking when MMU is -# disabled (MMU ENABLE=0) and using Happy Hare client macros. Note that only -# pause and cancel can occur in this mode and would typically be enabled -# -# The operations are as follows: -# toolchange - normal toolchange initiated with Tx or MMU_CHANGE_TOOL command -# runout - when a forced toolchange occurs as a result of runout -# load - individual MMU_LOAD operation -# unload - individual MMU_UNLOAD/MMU_EJECT operation -# complete - when print is complete (Happy Hare enabled) -# pause - a regular klipper PAUSE -# cancel - a regular klipper CANCEL_PRINT -# -# It is possible to call the parking macro manually in this form should you wish -# to include in your macros. -# -# _MMU_PARK FORCE_PARK=1 X=10 Y=10 Z_HOP=5 -# -# restore_xy_pos -# Controls where the toolhead (x,y) is returned to after an operation that -# invokes a parking move: -# last - return to original position before park (frequently the default) -# next - return to next print position if possible else last logic will be applied. -# In print this reduces dwell time at the last position reducing blobbing -# and unnecessary movement. Only applied to "toolchange" operation -# none - the toolhead is left wherever it ends up after change. In a print the -# next gcode command will restore toolhead x,y position -# -# Notes: -# - The starting z-height will always be restored, thus the different between 'next' -# and 'none' is the z-height at which the (x,y) move occurs and the location of -# of any un-retract -# - The default parking logic is a straight line move to the 'park_*' position. -# To implement fancy movement and control you can specify your own -# 'user_park_move_macro' to use instead of default straight line move -# - x,y parking coordinates can be negative if your printer can handle it -# -# Retraction can be used to optimize stringing and blobs that can occur when -# changing tools and are active only during a print. -# IMPORTANT: For toolchanging the config order would be: -# 1. In mmu_parameters.cfg configure extruder dimensions like -# 'toolhead_extruder_to_nozzle',etc. These are based on geometry. -# 2. In mmu_parameters.cfg tweak 'toolhead_ooze_reduction' only if necessary -# so that filament _just_ appears at the nozzle on load -# 3. Only then, adjust retraction to control stringing and blobs when -# changing tool in a print -variable_enable_park_printing : 'toolchange,runout,load,unload,complete,pause,cancel' ; Empty '' to disable parking -variable_enable_park_standalone : 'toolchange,load,unload,pause,cancel' ; Empty '' to disable parking -variable_enable_park_disabled : 'pause,cancel' ; Empty '' to disable parking - -variable_min_toolchange_z : 1.0 ; The absolute minimum safety floor (z-height) for ALL parking moves - -# These specify the parking location, z_hop and retraction for all enabled operation -# types. Each must be 5 values: -# x_coord, y_coord, z_hop(delta), z_hop_ramp, retraction length -# Use -999,-999 for no x,y move (you can just have z_hop). Use 0 for no z_hop -# The z_hop ramp is the horizontal distance in mm to travel during the lift. The -# direction is automatic and only applied if lifting the first time from print. -# This move is useful to help break the filament "string" -variable_park_toolchange : -999, -999, 1, 5, 2 ; x,y,z-hop,z_hop_ramp,retract for "toolchange" operations (toolchange,load,unload) -variable_park_runout : -999, -999, 1, 5, 2 ; x,y,z-hop,z_hop_ramp,retract -variable_park_pause : 50, 50, 5, 0, 2 ; x,y,z-hop,z_hop_ramp,retract (park position when mmu error occurs) -variable_park_cancel : -999, -999, 10, 0, 5 ; x,y,z-hop,z_hop_ramp,retract -variable_park_complete : 50, 50, 10, 0, 5 ; x,y,z-hop,z_hop_ramp,retract - -# For toolchange operations, this allows to you to specify additional parking moves -# at various stages of the toolchange. Each must have 3 values: -# x_coord, y_coord, z_hop(delta) -# Use -999,-999,0 for no movement at that stage (no-op). -# All movement will be at the established movement plane (z-height) -variable_pre_unload_position : -999, -999, 0 ; x,y,z-hop position before unloading starts -variable_post_form_tip_position : -999, -999, 0 ; x,y,z-hop position after form/cut tip on unload -variable_pre_load_position : -999, -999, 0 ; x,y,z-hop position before loading starts - -variable_restore_xy_pos : "last" ; last|next|none - What x,y position the toolhead should travel to after a "toolchange" - -variable_park_travel_speed : 200 ; Speed for any travel movement XY(Z) in mm/s -variable_park_lift_speed : 15 ; Z-only travel speed in mm/s -variable_retract_speed : 30 ; Speed of the retract move in mm/s -variable_unretract_speed : 30 ; Speed of the unretract move in mm/s - -# ADVANCED: Normally x,y moves default to 'G1 X Y' to park position. This allows -# you to create exotic movements. Macro will be provided the following parameters: -# YOUR_MOVE_MACRO X= Y= F= -# when restoring the from parked postion the same macro is called but passed a RESTORE=1 parameter, along with co-ordinates to restore to -# YOUR_MOVE_MACRO RESTORE=1 X= Y= F= -variable_user_park_move_macro : '' ; Executed instead of default 'G1 X Y move' to park position - -variable_auto_home : True ; True = automatically home if necessary, False = disable -variable_timelapse : False ; True = take frame snapshot after load, False = disable - -# Instead of completely defining your your own macros you can can extend functionality -# of default sequence macros by adding a command (or call to your gcode macro) -variable_user_mmu_error_extension : '' ; Executed after default logic when mmu error condition occurs -variable_user_pre_unload_extension : '' ; Executed after default logic -variable_user_post_form_tip_extension : '' ; Executed after default logic -variable_user_post_unload_extension : '' ; Executed after default logic -variable_user_pre_load_extension : '' ; Executed after default logic -variable_user_post_load_extension : '' ; Executed after default logic but before restoring toolhead position - - -# CUT_TIP ----------------------------------------------------------------- -# ██████╗██╗ ██╗████████╗ ████████╗██╗██████╗ -# ██╔════╝██║ ██║╚══██╔══╝ ╚══██╔══╝██║██╔══██╗ -# ██║ ██║ ██║ ██║ ██║ ██║██████╔╝ -# ██║ ██║ ██║ ██║ ██║ ██║██╔═══╝ -# ╚██████╗╚██████╔╝ ██║ ██║ ██║██║ -# ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ -# Don't need to configure if using tip forming -# (base/mmu_cut_tip.cfg) -# -[gcode_macro _MMU_CUT_TIP_VARS] -description: Happy Hare toolhead tip cutting macro configuration variables -gcode: # Leave empty - -# Whether the toolhead tip cutting macro will return toolhead to initial position -# after the cut is complete. If using parking logic it is better to disable this -variable_restore_position : False ; True = return to initial position, False = don't return - -# Distance from the internal nozzle tip to the cutting blade. This dimension -# is based on your toolhead and should not be used for tuning -# Note: If you have a toolhead sensor this variable can be automatically determined! -# Read https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing -variable_blade_pos : 37.5 ; TUNE ME: Distance in mm from internal nozzle tip - -# Distance to retract prior to making the cut, measured from the internal nozzle -# tip. This reduces wasted filament (left behind in extruder) but might cause a -# clog if set too large. This must be less than 'blade_pos' -# Note: the residual filament left in nozzle ('toolhead_ooze_reduction') is -# subtracted from this value so make sure toolhead is calibrated -variable_retract_length : 32.5 ; TUNE ME: 5mm less than 'blade_pos' is a good starting point - -# Whether to perform a simple tip forming move after the initial retraction -# Enabling this adds gives some additional cooling time of molten filament and -# may help avoid potential clogging on some hotends -variable_simple_tip_forming : True ; True = Perform simple tip forming, False = skip - -# Change to X and Y stepper current during the cut operation. Technically any stepper -# current can be modified by adding the stepper name to the list. Be careful not to -# overload your steppers but generally up to 150% is safe -variable_cut_axis_steppers : 'stepper_x, stepper_y' ; Comma separated list of stepper names to increase current for cutting -variable_cut_stepper_current : 100 ; % of stepper current to use for cutting motion (100 to disable) - -# This should be the position of the toolhead where the cutter arm just -# lightly touches the depressor pin -variable_cutting_axis : "x" ; "x" or "y". Determines cut direction (axis) during cut motion, used for park distance -variable_pin_loc_xy : 14, 250 ; x,y coordinates of depressor pin - -# This distance is added (or subtracted if negative) to "pin_loc_x" or "pin_loc_y" depending on the 'cutting_axis' -# This is used to determine the starting position and to create a small safety distance that aids in generating momentum. -# For example, Filametrix cuts along X axis towards Xmin requiring a positive value, whereas A4T-A4C cuts on Y axis towards -# Ymax requring a negative number -variable_pin_park_dist : 5.0 ; Distance in mm (+ve or -ve) from 'variable_pin_loc_xy' - -# Position of the toolhead when the cutter is fully compressed. Should leave a small headroom from the -# extremes of your printer edges (e.g. it should be a bit larger than 0, or whatever Xmin is) to avoid -# banging the toolhead or gantry. Typically x position will match x in pin_loc_xy if cutting in y direction -# or y position will match y in pin_loc_xy if cutting in x direction, but diagonal cuts are possible -variable_pin_loc_compressed_xy : 0.5, 250 ; x,y coordinates of fully depressed location - -# Retract length and speed after the cut so that the cutter blade doesn't -# get stuck on return to origin position -variable_rip_length : 1.0 ; Distance in mm to retract to aid lever decompression (>= 0) -variable_rip_speed : 3 ; Speed mm/s - -# Pushback of the remaining tip from the cold end into the hotend. This does -# not have to push back all the way, just sufficient to ensure filament fragment -# stays in hot end and the "nail head" of the cut is pushed back past the -# PTFE/metal junction so it cannot cause clogging problems on future loads. -# Cannot be larger than 'retract_length' - `toolhead_ooze_reduction` -variable_pushback_length : 15.0 ; TUNE ME: PTFE tube length + 3mm is good starting point -variable_pushback_dwell_time : 0 ; Time in ms to dwell after the pushback - -# Speed related settings for tip cutting -# Note that if the cut speed is too fast, the steppers can lose steps. -# Therefore, for a cut: -# - We first make a fast move to accumulate some momentum and get the cut -# blade to the initial contact with the filament -# - We then make a slow move for the actual cut to happen -variable_travel_speed : 150 ; Speed mm/s -variable_cut_fast_move_speed : 32 ; Speed mm/s -variable_cut_slow_move_speed : 8 ; Speed mm/s -variable_evacuate_speed : 150 ; Speed mm/s -variable_cut_dwell_time : 50 ; Time in ms to dwell at the cut point -variable_cut_fast_move_fraction : 1.0 ; Fraction of the move that uses fast move -variable_extruder_move_speed : 25 ; Speed mm/s for all extruder movement - -# Safety margin for fast vs slow travel. When traveling to the pin location -# we make a safer but longer move if we are closer to the pin than this -# specified margin. Usually setting these to the size of the toolhead -# (plus a small margin) should be good enough -variable_safe_margin_xy : 30, 30 ; Approx toolhead width +5mm, height +5mm) - -# If gantry servo option is installed, enable the servo and set up and down -# angle positions -variable_gantry_servo_enabled : False ; True = enabled, False = disabled -variable_gantry_servo_down_angle: 55 ; Angle for when pin is deployed -variable_gantry_servo_up_angle : 180 ; Angle for when pin is retracted - - -# FORM_TIP ---------------------------------------------------------------- -# ███████╗ ██████╗ ██████╗ ███╗ ███╗ ████████╗██╗██████╗ -# ██╔════╝██╔═══██╗██╔══██╗████╗ ████║ ╚══██╔══╝██║██╔══██╗ -# █████╗ ██║ ██║██████╔╝██╔████╔██║ ██║ ██║██████╔╝ -# ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║ ██║ ██║██╔═══╝ -# ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║ ██║ ██║██║ -# ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ -# Don't need to configure if using tip cutting -# (base/mmu_form_tip.cfg) -# -[gcode_macro _MMU_FORM_TIP_VARS] -description: Happy Hare tip forming macro configuration variables -gcode: # Leave empty - -# Step 1 - Ramming -# Ramming is the initial squeeze of filament prior to cooling moves and is -# described in terms of total volume and progression of squeeze intensity -# printing/standalone. This can be separately controlled when printing or -# standalone -variable_ramming_volume : 0 ; Volume in mm^3, 0 = disabled (optionally let slicer do it) -variable_ramming_volume_standalone : 0 ; Volume in mm^3, 0 = disabled - -# Optionally set for temperature change (reduction). The wait will occur -# before nozzle separation if 'use_fast_skinnydip: False' else after cooling -# moves. Temperature will be restored after tip creation is complete -variable_toolchange_temp : 0 ; 0 = don't change temp, else temp to set -variable_toolchange_fan_assist : False ; Whether to use part cooling fan for quicker temp change -variable_toolchange_fan_name : '' ; Define the part fan name if not default [fan] e.g "fan_generic fan0" -variable_toolchange_fan_speed : 50 ; Fan speed % if using fan_assist enabled - -# Step 2 - Nozzle Separation -# The filament is then quickly separated from the meltzone by a fast movement -# before then slowing to travel the remaining distance to cooling tube. The -# initial fast movement should be as fast as extruder can comfortably perform. -# A good starting point# for slower move is unloading_speed_start/cooling_moves. -# Too fast a slower movement can lead to excessively long tips or hairs -variable_unloading_speed_start : 80 ; Speed in mm/s for initial fast movement -variable_unloading_speed : 18 ; Speed in mm/s for slow move to cooling zone - -# Step 3 - Cooling Moves -# The cooling move allows the filament to harden while constantly moving back -# and forth in the cooling tube portion of the extruder to prevent a bulbous -# tip forming. The cooling tube position is measured from the internal nozzle -# to just past the top of the heater block (often it is beneficial to add a -# couple of mm to ensure the tip is in the cooling section. The cooling tube -# length is then the distance from here to top of heatsink (this is the length -# length of the cooling moves). The final cooling move is a fast movement to -# break the string formed. -variable_cooling_tube_position : 35 ; Start of cooling tube. DragonST:35, DragonHF:30, Mosquito:30, Revo:35, RapidoHF:27 -variable_cooling_tube_length : 10 ; Movement length. DragonST:15, DragonHF:10, Mosquito:20, Revo:10, RapidoHF:10 -variable_initial_cooling_speed : 10 ; Initial slow movement (mm/s) to solidify tip and cool string if formed -variable_final_cooling_speed : 50 ; Fast movement (mm/s) Too fast: tip deformation on eject, Too Slow: long string/no separation -variable_cooling_moves : 4 ; Number of back and forth cooling moves to make (2-4 is a good start) - -# Step 4 - Skinnydip -# Skinnydip is an advanced final move that may have benefit with some -# material like PLA to burn off persistent very fine hairs. To work the -# depth of insertion is critical (start with it disabled and tune last) -# For reference the internal nozzle would be at a distance of -# cooling_tube_position + cooling_tube_length, the top of the heater -# block would be cooling_tube_length away. -variable_use_skinnydip : False ; True = enable skinnydip, False = skinnydip move disabled -variable_skinnydip_distance : 30 ; Distance to reinsert filament into hotend starting from end of cooling tube -variable_dip_insertion_speed : 30 ; Medium/Slow insertion speed mm/s - Just long enough to melt the fine hairs, too slow will pull up molten filament -variable_dip_extraction_speed : 70 ; Speed mm/s - Around 2x Insertion speed to prevents forming new hairs -variable_melt_zone_pause : 0 ; Pause if melt zone in ms. Default 0 -variable_cooling_zone_pause : 0 ; Pause if cooling zone after dip in ms. Default 0 -variable_use_fast_skinnydip : False ; False = Skip the toolhead temp change wait during skinnydip move - -# Step 5 - Parking -# Park filament ready to eject -variable_parking_distance : 0 ; Position mm to park the filament at end of tip forming, 0 = leave where filament ends up after tip forming -variable_extruder_eject_speed : 25 ; Speed mm/s used for parking_distance (and final_eject when testing) - - -# PURGE ------------------------------------------------------------------- -# ██████╗ ██╗ ██╗██████╗ ██████╗ ███████╗ -# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██╔════╝ -# ██████╔╝██║ ██║██████╔╝██║ ███╗█████╗ -# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ -# ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ -# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ -# -# Optional reference (bucket) purge. Blobifier if far better but this can be -# used as a basis for custom purge -# (base/mmu_purge.cfg) -# -[gcode_macro _MMU_PURGE_VARS] -description: Happy Hare reference purging macro configuration variables -gcode: # Leave empty - -# Set speed as fast as you can without the extruder skipping steps. Note that -# you can increase the extruder current for purging in mmu_parameters.cfg -variable_extruder_purge_speed : 2 ; Speed in mm/s for purging - - -# CLIENT MACROS ----------------------------------------------------------- -# ██████╗ █████╗ ██╗ ██╗███████╗███████╗ ██████╗ ███████╗███████╗██╗ ██╗███╗ ███╗███████╗ -# ██╔══██╗██╔══██╗██║ ██║██╔════╝██╔════╝ ██╔══██╗██╔════╝██╔════╝██║ ██║████╗ ████║██╔════╝ -# ██████╔╝███████║██║ ██║███████╗█████╗ ██████╔╝█████╗ ███████╗██║ ██║██╔████╔██║█████╗ -# ██╔═══╝ ██╔══██║██║ ██║╚════██║██╔══╝ ██╔══██╗██╔══╝ ╚════██║██║ ██║██║╚██╔╝██║██╔══╝ -# ██║ ██║ ██║╚██████╔╝███████║███████╗ ██║ ██║███████╗███████║╚██████╔╝██║ ╚═╝ ██║███████╗ -# ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ -# If using the recommended PAUSE/RESUME/CANCEL_PRINT macros shipped with -# Happy Hare these variables allow for customization and basic extension -# Note that most parameters are pulled from the "movement" (sequence) -# macro above and thus these are supplemental a -# (optional/client_macros.cfg) -# -[gcode_macro _MMU_CLIENT_VARS] -description: Happy Hare client macro configuration variables -gcode: # Leave empty - -variable_reset_ttg_on_cancel : False ; True/False, Whether reset TTG map if print is canceled -variable_unload_tool_on_cancel : False ; True/False, Whether to unload the tool on cancel - -# You can extend functionality by adding a command (or call to your gcode macro) -variable_user_pause_extension : '' ; Executed after the klipper base pause -variable_user_resume_extension : '' ; Executed before the klipper base resume -variable_user_cancel_extension : '' ; Executed before the klipper base cancel_print - - -########################################################################### -# Tool change macros -# This is automatically created on installation but you can increase or -# reduce this list to match your number of tools in operation -# Note: it is annoying to have to do this but interfaces like Mainsail rely -# on real macro definitions for tools to be visible in the UI -# -{tx_macros} diff --git a/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg b/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg deleted file mode 100644 index 090a37147dff..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg +++ /dev/null @@ -1,820 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Main configuration parameters for the klipper module -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# Notes: -# Macro configuration is specified separately in 'mmu_macro_vars.cfg'. -# Full details in https://github.com/moggieuk/Happy-Hare/tree/main/doc/configuration.md -# -[mmu] -happy_hare_version: {happy_hare_version} # Don't mess, used for upgrade detection - -# MMU Hardware Limits -------------------------------------------------------------------------------------------------- -# ██╗ ██╗███╗ ███╗██╗████████╗███████╗ -# ██║ ██║████╗ ████║██║╚══██╔══╝██╔════╝ -# ██║ ██║██╔████╔██║██║ ██║ ███████╗ -# ██║ ██║██║╚██╔╝██║██║ ██║ ╚════██║ -# ███████╗██║██║ ╚═╝ ██║██║ ██║ ███████║ -# ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ -# -# Define the physical limits of your MMU. These settings will be respected regardless of individual speed settings. -# -gear_max_velocity: 300 # Never to be exceeded gear velocity regardless of specific parameters -gear_max_accel: 1500 # Never to be exceeded gear acceleration regardless of specific parameters -selector_max_velocity: 250 # Never to be exceeded selector velocity regardless of specific parameters -selector_max_accel: 1200 # Never to be exceeded selector acceleration regardless of specific parameters - - -# Servo configuration ------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ -# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗ -# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║ -# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║ -# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝ -# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ -# -# Angle of the servo in three named positions -# up = tool is selected and filament is allowed to freely move through gate -# down = to grip filament -# move = ready the servo for selector move (optional - defaults to up) -# V2.4.0 on: These positions are only for initial config they are replaced with calibrated servo positions in 'mmu_vars.cfg' -# -# Note that leaving the servo active when down can stress the electronics and is not recommended with EASY-BRD or ERB board -# unless the 5v power supply has been improved and it is not necessary with standard ERCF builds -# Make sure your hardware is suitable for the job! -# -servo_up_angle: 145 # ERCF: MG90S: 30 ; SAVOX SH0255MG: 140 ; Tradrack: 145 -servo_down_angle: 30 # ERCF: MG90S: 140 ; SAVOX SH0255MG: 30 ; Tradrack: 1 -servo_move_angle: 109 # Optional angle used when selector is moved (defaults to up position) -servo_duration: 0.4 # Duration of PWM burst sent to servo (default non-active mode, automatically turns off) -servo_dwell: 0.5 # Minimum time given to servo to complete movement prior to next move -servo_always_active: 0 # CAUTION - WILL DAMAGE COMMON SERVOS, PLEASE USE AT YOUR OWN RISK: 1=Force servo to always stay active, 0=Release after movement -servo_active_down: 0 # CAUTION - WILL DAMAGE COMMON SERVOS, PLEASE USE AT YOUR OWN RISK: 1=Force servo to stay active when down only, 0=Release after movement -servo_buzz_gear_on_down: 0 # Whether to "buzz" the gear stepper on down to aid engagement - - -# Logging -------------------------------------------------------------------------------------------------------------- -# ██╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ -# ██║ ██╔═══██╗██╔════╝ ██╔════╝ ██║████╗ ██║██╔════╝ -# ██║ ██║ ██║██║ ███╗██║ ███╗██║██╔██╗ ██║██║ ███╗ -# ██║ ██║ ██║██║ ██║██║ ██║██║██║╚██╗██║██║ ██║ -# ███████╗╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝ -# ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# log_level & logfile_level can be set to one of (0 = essential, 1 = info, 2 = debug, 3 = trace, 4 = developer + stepper moves) -# Generally you can keep console logging to a minimal whilst still sending debug output to the mmu.log file -# Increasing the console log level is only really useful during initial setup to save having to constantly open the log file -# Note: that it is not recommended to keep logging at level greater that 2 (debug) if not debugging an issue because -# of the additional overhead -# -log_level: 1 -log_file_level: 2 # Can also be set to -1 to disable log file completely -log_statistics: 1 # 1 to log statistics on every toolchange (default), 0 to disable (but still recorded) -log_visual: 1 # 1 log visual representation of filament, 0 = disable -log_startup_status: 1 # Whether to log tool to gate status on startup, 1 = summary (default), 0 = disable -log_m117_messages: 1 # Whether send toolchange message via M117 to screen - - -# Movement speeds ------------------------------------------------------------------------------------------------------ -# ███████╗██████╗ ███████╗███████╗██████╗ ███████╗ -# ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝ -# ███████╗██████╔╝█████╗ █████╗ ██║ ██║███████╗ -# ╚════██║██╔═══╝ ██╔══╝ ██╔══╝ ██║ ██║╚════██║ -# ███████║██║ ███████╗███████╗██████╔╝███████║ -# ╚══════╝╚═╝ ╚══════╝╚══════╝╚═════╝ ╚══════╝ -# -# Long moves are faster than the small ones and used for the bulk of the bowden movement. You can set two fast load speeds -# depending on whether pulling from the spool or filament buffer (if fitted and not the first time load). This can be helpful -# in allowing faster loading from buffer and slower when pulling from the spool because of the additional friction (prevents -# loosing steps). Unloading speed can be tuning if you have a rewinder system that imposes additional limits. -# NOTE: Encoder cannot keep up much above 450mm/s so make sure 'bowden_apply_correction' is off at very high speeds! -# -gear_from_spool_speed: 80 # mm/s Speed when loading from the spool (for the first time if has_filament_buffer: 1) -gear_from_spool_accel: 100 # Acceleration when loading from spool -gear_from_buffer_speed: 150 # mm/s Speed when loading filament from buffer. Conservative is 100mm/s, Max around 400mm/s -gear_from_buffer_accel: 400 # Normal acceleration when loading filament -gear_unload_speed: 80 # mm/s Use (lower) speed when unloading filament (defaults to "from spool" speed) -gear_unload_accel: 100 # Acceleration when unloading filament (defaults to "from spool" accel) -# -gear_short_move_speed: 80 # mm/s Speed when making short moves (like incremental retracts with encoder) -gear_short_move_accel: 600 # Usually the same as gear_from_buffer_accel (for short movements) -gear_short_move_threshold: 70 # Move distance that controls application of 'short_move' speed/accel -gear_homing_speed: 50 # mm/s Speed of gear stepper only homing moves (e.g. homing to gate or extruder) - -# Speeds of extruder movement. The 'sync' speeds will be used when gear and extruder steppers are moving in sync -# -extruder_load_speed: 16 # mm/s speed of load move inside extruder from homing position to meltzone -extruder_unload_speed: 16 # mm/s speed of unload moves inside of extruder (very initial move from meltzone is 50% of this) -extruder_sync_load_speed: 18 # mm/s speed of synchronized extruder load moves -extruder_sync_unload_speed: 18 # mm/s speed of synchronized extruder unload moves -extruder_homing_speed: 18 # mm/s speed of extruder only homing moves (e.g. to toolhead sensor) - -# Selector movement speeds. (Acceleration is defined by physical MMU limits set above and passed to selector stepper driver) -# -selector_move_speed: 200 # mm/s speed of selector movement (not touch) -selector_homing_speed: 60 # mm/s speed of initial selector homing move (not touch) -selector_touch_speed: 80 # mm/s speed of all touch selector moves (if stallguard configured) - -# Selector touch (stallguard) operation. If stallguard is configured, then this can be used to switch on touch movement which -# can detect blocked filament path and try to recover automatically but it is more difficult to set up -# -selector_touch_enabled: 0 # If selector touch operation configured this can be used to disable it 1=enabled, 0=disabled - -# When Happy Hare calls out to a macro for user customization and for parking moves these settings are applied and the previous -# values automatically restored afterwards. This allows for deterministic movement speed regardless of the starting state. -# -macro_toolhead_max_accel: 0 # Default printer toolhead acceleration applied when macros are run. 0 = use printer max -macro_toolhead_min_cruise_ratio: 0.5 # Default printer cruise ratio applied when macros are run - - -# Gate loading/unloading ----------------------------------------------------------------------------------------------- -# ██████╗ █████╗ ████████╗███████╗ ██╗ ██████╗ █████╗ ██████╗ -# ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██║ ███╗███████║ ██║ █████╗ ██║ ██║ ██║███████║██║ ██║ -# ██║ ██║██╔══██║ ██║ ██╔══╝ ██║ ██║ ██║██╔══██║██║ ██║ -# ╚██████╔╝██║ ██║ ██║ ███████╗ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -# These settings control the loading and unloading filament at the gate which is the parking position inside the MMU. -# Typically this would be switch sensor but you can also use an encoder. Even with encoder the endstop can be a switch -# and the encoder used for move verifcation (see advanced 'gate_endstop_to_encoder' option). Note that the `encoder` -# method, due to the nature of its operation will overshoot a little. This is not a problem in practice because the -# overshoot will simply be compensated for in the subsequent move. A +ve parking distance moves towards the MMU, -ve -# moves back through the endstop towards the toolhead. If the MMU has multiple bowden tubes then it is possible to home -# at the extruder sensor and avoid long bowden moves! -# -# Possible gate_homing_endstop names: -# encoder - Detect filament position using movement of the encoder -# mmu_gate - Use gate endstop -# mmu_gear - Use individual per-gate endstop (type-B MMU's) -# extruder - Use extruder entry sensor (Only for some type-B designs, see [mmu_machine] require_bowden_move setting) -# -gate_homing_endstop: encoder # Name of gate endstop, "encoder" forces use of encoder for parking -gate_homing_max: 70 # Maximum move distance to home (or actual move distance if encoder endstop) -gate_parking_distance: 23 # Parking position relative to homing endstop (-ve value means move forward) -gate_preload_homing_max: 300 # Maximum homing distance to the mmu_gear endstop (if MMU is fitted with one) -gate_preload_parking_distance: -10 # Parking position relative to mmu_gear endstop (-ve value means move forward) -gate_endstop_to_encoder: 10 # Distance between gate endstop and encoder (IF both fitted. +ve if encoder after endstop) -gate_unload_buffer: 50 # Amount to reduce the fast unload so that filament doesn't overshoot when parking -gate_load_retries: 2 # Number of times MMU will attempt to grab the filament on initial load (type-A designs) -gate_autoload: 1 # If pre-gate sensor fitted this controls the automatic loading of the gate -gate_final_eject_distance: 0 # Additional distance to eject filament on MMU_EJECT to clear MMU grip - - -# Bowden tube loading/unloading ---------------------------------------------------------------------------------------- -# ██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ -# ██╔══██╗██╔═══██╗██║ ██║██╔══██╗██╔════╝████╗ ██║ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██████╔╝██║ ██║██║ █╗ ██║██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║███████║██║ ██║ -# ██╔══██╗██║ ██║██║███╗██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██║██║ ██║ -# ██████╔╝╚██████╔╝╚███╔███╔╝██████╔╝███████╗██║ ╚████║ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -bowden_homing_max: 2000 # Maximum attempted bowden move (for calibration). Should be larger than your actual bowden! - -# If you MMU is equiped with an encoder the following options are available: -# -# In addition to different bowden loading speeds for buffer and non-buffered filament it is possible to detect missed -# steps caused by "jerking" on a heavy spool. If bowden correction is enabled Happy Hare will "believe" the encoder -# reading and make correction moves to bring the filament to within the 'bowden_allowable_load_delta' of the end of -# bowden position (this does require a reliable encoder and is not recommended for very high speed loading >350mm/s) -# -bowden_apply_correction: 0 # 1 to enable, 0 disabled -bowden_allowable_load_delta: 20.0 # How close in mm the correction moves will attempt to get to target -# -# This saftey check uses the encoder to verify the filament is free of extruder before the fast bowden movement to -# reduce possibility of grinding filament. If enabled the trigger can be tuned by setting the "error tolerance" which -# represents the fraction of allowable mismatch between actual movement and that seen by encoder. Setting to 50% tolerance -# usually works well. Increasing will make test more tolerant. Value of 100% essentially disables error detection -# -bowden_pre_unload_test: 1 # 1 to check for bowden movement before full pull (slower), 0 don't check (faster) -bowden_pre_unload_error_tolerance: 50 # ADVANCED: tune pre_unload_test - - -# Extruder homing ----------------------------------------------------------------------------------------------------- -# ███████╗██╗ ██╗████████╗ ██╗ ██╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ -# ██╔════╝╚██╗██╔╝╚══██╔══╝ ██║ ██║██╔═══██╗████╗ ████║██║████╗ ██║██╔════╝ -# █████╗ ╚███╔╝ ██║ ███████║██║ ██║██╔████╔██║██║██╔██╗ ██║██║ ███╗ -# ██╔══╝ ██╔██╗ ██║ ██╔══██║██║ ██║██║╚██╔╝██║██║██║╚██╗██║██║ ██║ -# ███████╗██╔╝ ██╗ ██║██╗ ██║ ██║╚██████╔╝██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ -# ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# Happy Hare needs a reference "homing point" close to the extruder from which to accurately complete the loading of -# the toolhead. This homing operation takes place after the fast bowden load and it is anticipated that that load -# operation will leave the filament just shy of the homing point. If using a toolhead sensor this initial extruder -# homing is unnecessary (but can be forced) because the homing will occur inside the extruder for the optimum in accuracy. -# You still should set this homing method because it is also used for the determination and calibration of bowden length. -# -# In addition to an entry sensor "extruder" it is possible for Happy Hare to "feel" for the extruder gear entry -# by colliding with it. This can be done with encoder based collision detection, the compression of the sync-feedback -# (aka buffer) sensor or using "touch" (stallguard) on the gear stepper. Note that encoder collision detection is not -# completely deterministic and you will have to find the sweetspot for your setup by adjusting the TMC current reduction. -# Note that reduced current during collision detection can also prevent unecessary filament griding. -# -# Possible extruder_homing_endtop names: -# filament_compression - If you have a "sync-feedback" sensor with compression switch configured -# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home -# extruder - If you have a "filament entry" endstop configured (Requires 'extruder' endstop) -# Fast bowden load will move to extruder_homing_buffer distance before sensor, then home -# collision - Detect the collision with the extruder gear by monitoring encoder movement (Requires encoder) -# Fast bowden load will move to the extruder gears -# mmu_gear_touch - Use touch detection when the gear stepper hits the extruder (Requires stallguard) -# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home -# none - Don't attempt to home. Only possibiliy if lacking all sensor options -# Fast bowden load will move to the extruder gears. Option is fine if using toolhead sensor -# Note: The homing_endstop will be ignored ("none") if a toolhead sensor is available unless "extruder_force_homing: 1" -# -extruder_homing_max: 80 # Maximum distance to advance in order to attempt to home the extruder -extruder_homing_endstop: collision # Filament homing method/endstop name (fallback if toolhead sensor not available) -extruder_homing_buffer: 25 # Amount to reduce the fast bowden load so filament doesn't overshoot the extruder homing point -extruder_collision_homing_current: 30 # % gear_stepper current (10%-100%) to use when homing to extruder homing (100 to disable) - -# If you have a toolhead sensor it will always be used as a homing point making the homing outside of the extruder -# potentially unnecessary. However you can still force this initial homing step by setting this option in which case -# the filament will home to the extruder and then home to the toolhead sensor in two steps -# -extruder_force_homing: 0 - - -# Toolhead loading and unloading -------------------------------------------------------------------------------------- -# ████████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗███████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ -# ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██║ ██║ ██║██║ ██║██║ ███████║█████╗ ███████║██║ ██║ ██║ ██║ ██║███████║██║ ██║ -# ██║ ██║ ██║██║ ██║██║ ██╔══██║██╔══╝ ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║ ██║ -# ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║███████╗██║ ██║██████╔╝ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -# It is possible to define highly customized loading and unloading sequences, however, unless you have a specialized -# setup it is probably easier to opt for the built-in toolhead loading and unloading sequence which already offers a -# high degree of customization. If you need even more control then edit the _MMU_LOAD_SEQUENCE and _MMU_UNLOAD_SEQUENCE -# macros in mmu_sequence.cfg - but be careful! -# -# An MMU must have a known point at the end of the bowden from which it can precisely load the extruder. Generally this -# will either be the extruder entrance (which is controlled with settings above) or by homing to toolhead sensor. If -# you have toolhead sensor it is past the extruder gear and the driver needs to know the max distance (from end of -# bowden move) to attempt homing -# -toolhead_homing_max: 40 # Maximum distance to advance in order to attempt to home to defined homing endstop - -# IMPORTANT: These next three settings are based on the physical dimensions of your toolhead -# Once a homing position is determined, Happy Hare needs to know the final move distance to the nozzle. There is only -# one correct value for your setup - use 'toolhead_ooze_reduction' (which corresponds to the residual filament left in -# your nozzle) to control excessive oozing on load. See doc for table of proposed values for common configurations. -# -# NOTE: If you have a toolhead sensor you can automate the calculation of these parameters! Read about the -# `MMU_CALIBRATE_TOOLHEAD` command (https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing#---calibrating-toolhead) -# -toolhead_extruder_to_nozzle: 72 # Distance from extruder gears (entrance) to nozzle -toolhead_sensor_to_nozzle: 62 # Distance from toolhead sensor to nozzle (ignored if not fitted) -toolhead_entry_to_extruder: 8 # Distance from extruder "entry" sensor to extruder gears (ignored if not fitted) - -# This setting represents how much residual filament is left behind in the nozzle when filament is removed, it is thus -# used to reduce the extruder loading length and prevent excessive blobbing but also in the calculation of purge volume. -# Note that this value can also be measured with the `MMU_CALIBRATE_TOOLHEAD` procedure -# -toolhead_residual_filament: 0 # Reduction in extruder loading length because of residual filament left behind - -# TUNING: Finally, this is the last resort tuning value to fix blobbing. It is expected that this value is NEAR ZERO as -# it represents a further reduction in extruder load length to fix blobbing. If using a wipetower and you experience blobs -# on it, increase this value (reduce the quantity of filament loaded). If you experience gaps, decrease this value. If gaps -# and already at 0 then perhaps the 'toolhead_extruder_to_nozzle' or 'toolhead_residual_filament' settings are incorrect. -# Similarly a value >+5mm also suggests the four settings above are not correct. Also see 'retract' setting in -# 'mmu_macro_vars.cfg' for final in-print ooze tuning. -# -toolhead_ooze_reduction: 0 # Reduction in extruder loading length to prevent ooze (represents filament remaining) - -# Distance added to the extruder unload movement to ensure filament is free of extruder. This adds some degree of tolerance -# to slightly incorrect configuration or extruder slippage. However don't use as an excuse for incorrect toolhead settings -# -toolhead_unload_safety_margin: 10 # Extra movement safety margin (default: 10mm) - -# If not synchronizing gear and extruder and you experience a "false" encoder clog detection immediately after the tool -# change it might be because of a long bowden and/or large internal diameter that causes slack in the filament. This optional -# move will tighten the filament after a load by % of current clog detection length. Gear stepper will run at 50% current -# -toolhead_post_load_tighten: 60 # % of clog detection length, 0 to disable. Ignored if 'sync_to_extruder: 1' - -# If synchronizing gear and extruder and you have a sync-feedback "buffer" (switch based or proportional) this setting -# determines whether to use it to create neutral tension after loading -toolhead_post_load_tension_adjust: 1 # 1 to enable (recommended), 0 to disable - -# If sync-feedback compression sensor is available this test will ensure the filament passes the extruder entry by checking -# for neutral tension when moving filament with just the extruder. Recommended with sprung loaded sync-feedback buffers. -# This is ignored if toolhead sensor is available. -toolhead_entry_tension_test: 1 # 1 to enable (recommended), 0 to disable - -# ADVANCED: Controls the detection of successful extruder load/unload movement and represents the fraction of allowable -# mismatch between actual movement and that seen by encoder. Setting to 100% tolerance effectively turns off checking. -# Some designs of extruder have a short move distance that may not be picked up by encoder and cause false errors. This -# allows masking of those errors. However the error often indicates that your extruder load speed is too high or the -# friction is too high on the filament and in that case masking the error is not a good idea. Try reducing friction -# and lowering speed first! -# -toolhead_move_error_tolerance: 60 - - -# Tip forming --------------------------------------------------------------------------------------------------------- -# ████████╗██╗██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ -# ╚══██╔══╝██║██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ -# ██║ ██║██████╔╝ █████╗ ██║ ██║██████╔╝██╔████╔██║██║██╔██╗ ██║██║ ███╗ -# ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██║ ██║ -# ██║ ██║██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# Tip forming responsibility can be split between slicer (in-print) and standalone macro (not in-print) or forced to always -# be done by Happy Hare's standalone macro. Since you always need the option to form tips without the slicer so it is -# generally easier to completely turn off the slicer, force "standalone" tip forming and tune only in Happy Hare. -# -# When Happy Hare is asked to form a tip it will run the referenced macro. Two are reference examples are provided but -# you can implement your own: -# _MMU_FORM_TIP .. default tip forming similar to popular slicers like Superslicer and Prusaslicer -# _MMU_CUT_TIP .. for Filametrix (originally ERCFv2) or similar style toolhead filament cutting system -# -# NOTE: For MMU located cutting like the optional EREC cutter you should set still this to _MMU_FORM_TIP to build a decent -# tip prior to extraction and cutting after the unload. -# -# Often it is useful to increase the extruder current for the rapid movement to ensure high torque and no skipped steps -# -# If opting for slicer tip forming you MUST configure where the slicer leaves the filament in the extruder since -# there is no way to determine this. This can be ignored if all tip forming is performed by Happy Hare -# -force_form_tip_standalone: 1 # 0 = Slicer in print else standalone, 1 = Always standalone tip forming (TURN SLICER OFF!) -form_tip_macro: _MMU_FORM_TIP # Name of macro to call to perform the tip forming (or cutting) operation -extruder_form_tip_current: 100 # % of extruder current (100%-150%) to use when forming tip (100 to disable) -slicer_tip_park_pos: 0 # This specifies the position of filament in extruder after slicer completes tip forming - - -# Purging ------------------------------------------------------------------------------------------------------------- -# ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ -# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██║████╗ ██║██╔════╝ -# ██████╔╝██║ ██║██████╔╝██║ ███╗██║██╔██╗ ██║██║ ███╗ -# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║ -# ██║ ╚██████╔╝██║ ██║╚██████╔╝██║██║ ╚████║╚██████╔╝ -# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# After a toolchange it is necessary to purge the old filament. Similar to tip forming this can be done by the slicer and/or -# by Happy Hare using an extension like Blobifer. If a purge_macro is defined it will be called when not printing or whenever -# the slicer isn't going to purge (like initial tool load). You can force it to always be called in a print by setting -# force_purge_standalone, but remember to turn off the slicer wipetower -# -# The default is for no (empty) macro so purging will not be done out of a print and thus wipetower. Two options are shipped with -# Happy Hare but you can also build your own custom one: -# _MMU_PURGE .. default purging that just dumps the desired amount of filament (setup correct parking before enabling this!) -# BLOBIFIER .. for excellent Blobifer addon (https://github.com/Dendrowen/Blobifier) -# -# Often it is useful to increase the extruder current for the often rapid puring movement to ensure high torque and no skipped steps -# -force_purge_standalone: 0 # 0 = Slicer wipetower in print else standalone, 1 = Always standalone purging (TURN WIPETOWER OFF!) -purge_macro: # Name of macro to call to perform the standalone purging operation. E.g. BLOBIFIER, _MMU_PURGE -extruder_purge_current: 100 # % of extruder current (100%-150%) to use when purging (100 to disable) - - -# Synchronized gear/extruder movement ---------------------------------------------------------------------------------- -# ███╗ ███╗ ██████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗███╗ ██╗ ██████╗ -# ████╗ ████║██╔═══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██╔════╝╚██╗ ██╔╝████╗ ██║██╔════╝ -# ██╔████╔██║██║ ██║ ██║ ██║ ██║██████╔╝ ███████╗ ╚████╔╝ ██╔██╗ ██║██║ -# ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║██╔══██╗ ╚════██║ ╚██╔╝ ██║╚██╗██║██║ -# ██║ ╚═╝ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║ ███████║ ██║ ██║ ╚████║╚██████╗ -# ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ -# -# This controls whether the extruder and gear steppers are synchronized during printing operations -# If you normally run with maxed out gear stepper current consider reducing it with 'sync_gear_current' -# If equipped with TMC drivers the current of the gear and extruder motors can be controlled to optimize performance. -# This can be useful to control gear stepper temperature when printing with synchronized motor -# -sync_to_extruder: 0 # Gear motor is synchronized to extruder during print -sync_gear_current: 70 # % of gear_stepper current (10%-100%) to use when syncing with extruder during print -sync_form_tip: 0 # Synchronize during standalone tip formation (initial part of unload) -sync_purge: 0 # Synchronize during standalone purging (last part of load) - -# Optionally it is possible to leverage feedback from a "compression/expansion" sensor (aka "buffer") in the bowden -# path from MMU to extruder to ensure that the two motors are kept in sync as viewed by the filament (the signal feedback -# state can be binary supplied by one or two switches: -1 (expanded) and 1 (compressed) of proportional value between -# -1.0 and 1.0. -# -# If only "one half" of the sync-feedback is available (either compression-only or tension-only) then the rotation -# distance is always shifted based on the high/low multipliers, however if both tension and compression are available -# then the rotation distance will autotune to correct setting (recommend you also enable 'autotune_rotation_distance: 1' -# so that values are saved) -# -# Possible buffer setups, forth option for type where neutral is when both sensors are active: -# -# <------maxrange------> <------maxrange------> <------maxrange------> <------maxrange------> -# <--range---> <----range-----> <----range-----> <> range=0 -# |====================| |====================| |====================| |====================| -# ^ ^ ^ ^ ^^ -# compression tension compression-only tension-only -# -sync_feedback_enabled: 0 # Turn off even if sensor is installed and active -sync_feedback_buffer_range: 6 # Travel in "buffer" between compression/tension or one sensor and end (see above) -sync_feedback_buffer_maxrange: 12 # Absolute maximum end-to-end travel (mm) provided by buffer (see above) -sync_feedback_speed_multiplier: 5 # % "twolevel" gear speed delta to keep filament neutral in buffer (recommend 5%) -sync_feedback_boost_multiplier: 3 # % "twolevel" extra gear speed boost for finding initial neutral position (recommend 3%) -sync_feedback_extrude_threshold: 5 # Extruder movement (mm) for updates (keep small but set > retract distance) - -# If defined this forces debugging to a telemetry log file "sync_.jsonl". This is great if trying to tune clog/tangle -# detection or for getting help on the Happy Hare forum. To plot graph of sync-feedback operation, run: -# ~/Happy-Hare/utils/plot_sync_feedback.sh -# -sync_feedback_debug_log: 0 # 0 = disable (normal operation), 1 = enable telemetry log (for debugging) - - -# ESpooler control ----------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ -# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ -# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ -# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ -# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ -# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ -# -# If your MMU has a dc motor (often N20) controlled respooler/assist then how it operates can be controlled with these -# settings. Typically the espooler will be controlled with PWM signal. This will be at the maximum at speeds equal or -# above 'espooler.max_stepper_speed'. The PWM signal will scale downwards towards 0 for slower speeds. The falloff being -# controlled by the 'espooler_speed_exponent' setting according to this formula and allows for non-linear characteristics -# the DC motor (0.5 is a good starting value). -# -# espooler_pwm = (stepper_speed / espooler_max_stepper_speed) ^ {espooler_speed_exponent} -# -# Regardless of h/w configuration you can enable/disable actions with the 'espooler_operations' list. E.g. remove 'play' to -# turn off operation while printing. Options are: -# -# rewind - when filament is being unloaded under MMU control (aka respool) -# assist - when filament is being loaded under MMU control (% of "rewind" speed but with minimum of "print" power) -# print - while printing. Generally set 'espooler_printing_power' to a low percentage just to allow motor to be turned -# freely or set to 0 to enable/allow "burst" assist movements -# -# If using a digitally controlled espooler motor (not PWM) then you should turn off the "print" mode and set -# 'espooler_min_stepper_speed' to prevent "over movement" -# -espooler_min_distance: 30 # Individual stepper movements less than this distance will not active espooler -espooler_max_stepper_speed: 300 # Gear stepper speed at which espooler will be at maximum power -espooler_min_stepper_speed: 0 # Gear stepper speed at which espooler will become inactive (useful for non PWM control) -espooler_speed_exponent: 0.5 # Controls non-linear espooler power relative to stepper speed (see notes) -espooler_assist_reduced_speed: 50 # Control the % of the rewind speed that is applied to assisting load (want rewind to be faster) -espooler_printing_power: 0 # If >0, fixes the % of PWM power while printing. 0=allows burst movement -espooler_operations: rewind, assist, print # List of operational modes (allows disabling even if h/w is configured) -# -# The following burst configuration is used to control the small rotation in the ASSIST direction optionally used -# when in 'print' operation is enabled, 'espooler_printing_power: 0' and is triggered (tension switch or extruder movement). -# It can also be used to loosen filament with 'MMU_ESPOOLER COMMAND=assist BURST=1' -# -espooler_assist_extruder_move_length: 100 # Distance (mm) extruder needs to move between each assist burst -espooler_assist_burst_power: 100 # The % power of the burst move -espooler_assist_burst_duration: 0.4 # The duration of the burst move is seconds -espooler_assist_burst_trigger: 0 # If trigger assist switch is fitted 0=disable, 1=enable -espooler_assist_burst_trigger_max: 3 # If trigger assist switch is fitted this limits the max number of back-to-back advances -# -# The following burst configuration is used to control the small rotation in the REWIND direction optionally used -# when running running the filament drying cycle. The goal is to rotate the spool 60-90 degrees. It can also be -# used to tighten the filament with 'MMU_ESPOOLER COMMAND=rewind BURST=1' -# -espooler_rewind_burst_power: 100 # The % power of the rewind burst move -espooler_rewind_burst_duration: 0.4 # The duration of the rewind burst move is seconds - - -# Heater / Environment Management ------------------------------------------------------------------------------------ -# ██╗ ██╗███████╗ █████╗ ████████╗███████╗██████╗ -# ██║ ██║██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ -# ███████║█████╗ ███████║ ██║ █████╗ ██████╔╝ -# ██╔══██║██╔══╝ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ -# ██║ ██║███████╗██║ ██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -heater_max_temp: 70 # Absolute max heater setting to protect the MMU enclosure construction (adjust to match material) -heater_default_dry_temp: 45 # Default drying temperature if filament type is not matched in drying_data -heater_default_dry_time: 300 # Default drying cycle time in minutes -heater_default_humidity: 25 # Default humidity % goal. Drying will terminate if this value is reached -heater_vent_macro: _MMU_VENT # Name of macro to periodicaly call during drying cycle -heater_vent_interval: 0 # Interval in minutes to call heater_vent_macro during drying cycle, 0=disable venting -heater_rotate_interval: 5 # Interval in minutes to rotate filament (requires eSpooler and filament end attached to spool) - -# Drying data for MMU_HEATER DRY=1 command in form (material type is case insensitive): -# 'filament_type': (temp, drying_time_mins) -# -# (Careful with formatting of this line - reformatting will break upgrade logic) -# -drying_data: { 'pla': (45, 300), 'pla+': (55, 300), 'petg': (60, 300), 'tpu': (55, 300), 'abs': (70, 300), 'abs+': (75, 300), 'asa': (65, 300), 'nylon': (75, 600), 'pc': (75, 600), 'pva': (75, 600), 'hips': (75, 600) } - - -# FlowGuard Clog and Tangle Detection -------------------------------------------------------------------------------- -# ███████╗██╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ -# ██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗ -# █████╗ ██║ ██║ ██║██║ █╗ ██║██║ ███╗██║ ██║███████║██████╔╝██║ ██║ -# ██╔══╝ ██║ ██║ ██║██║███╗██║██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║ -# ██║ ███████╗╚██████╔╝╚███╔███╔╝╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝ -# ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ -# -# Options are available to automatically detects extruder clogs and MMU tangles. Each option works independently and -# can be combined. Flowguard can even discern the difference between an extruder clog and a spool tangle! -# -# Flowguard: This intelligently measures filament tension (only available if sync-feedback buffer is fitted) -# -# Encoder detection: This monitors encoder movement and compares to extruder (only available if encoder is fitted) -# -flowguard_enabled: 1 # 0 = Flowguard protection disabled, 1 = Enabled - -# The flowguard_max_relief is the amount of relief movement (effective mm change in filament length between MMU and extruder) -# that Happy Hare will wait until triggering a clog or runout. A smaller value is more sensitive to triggering. Since the -# relief movement is hightly dependent on filament "spring" in the bowden tube, filament friction, and -# 'sync_feedback_buffer_range', it is generally good to start high and then decrease if a more sensitive trigger is desired. -# Analog proportional (type P) sensors can generally have a much lower value. Increase if you have false triggers. -flowguard_max_relief: 40 - -# Encoder runout/clog/tangle detection watches for movement over either a static or automatically adjusted distance - if -# no encoder movement is seen when the extruder moves this distance runout/ clog/tangle event will be generated. Allowing -# the distance to be adjusted automatically (mode=2) will generally allow for a quicker trigger but use a static length -# (mode=1, set encoder_max_motion) if you get false triggers (see flowguard guide on wiki for more details). -# Note that this feature cannot disinguish between clog or tangle. -flowguard_encoder_mode: 2 # 0 = Disable, 1 = Static length clog detection, 2 = Automatic length clog detection - -# The encoder_max_motion is the absolute max permitted extruder movement without the encoder seeing movement when using -# status mode (mode=1). Smaller values are more sensitive but beware of going too small - slack and friction in the -# bowden may cause gaps in encoder movement. Increase if you have false triggers. -# Note that this value is overriden by any calibrated value stored in 'mmu_vars.cfg' if in automatic mode (mode=2). -flowguard_encoder_max_motion: 20 - - -# Filament Management Options ---------------------------------------------------------------------------------------- -# ███████╗██╗██╗ ███╗ ███╗ ██████╗ ███╗ ███╗████████╗ -# ██╔════╝██║██║ ████╗ ████║██╔════╝ ████╗ ████║╚══██╔══╝ -# █████╗ ██║██║ ██╔████╔██║██║ ███╗██╔████╔██║ ██║ -# ██╔══╝ ██║██║ ██║╚██╔╝██║██║ ██║██║╚██╔╝██║ ██║ -# ██║ ██║███████╗██╗ ██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ -# -# - EndlessSpool feature allows detection of runout on one spool and the automatic mapping of tool to an alternative -# gate (spool). Set to '1', this feature requires clog detection or gate sensor or pre-gate sensors. EndlessSpool -# functionality can optionally be extended to attempt to load an empty gate with 'endless_spool_on_load'. On some MMU -# designs (with linear selector) it can also be configured to eject filament remains to a designated gate rather than -# defaulting to current gate. A custom gate will disable pre-gate runout detection for EndlessSpool because filament -# end must completely pass through the gate for selector to move -# -endless_spool_enabled: 1 # 0 = disable, 1 = enable endless spool -endless_spool_on_load: 0 # 0 = don't apply endless spool on load, 1 = run endless spool if gate is empty -endless_spool_eject_gate: -1 # Which gate to eject the filament remains. -1 = current gate -#endless_spool_groups: # Default EndlessSpool groups (see later in file) -# -# Spoolman support requires you to correctly enable spoolman with moonraker first. If enabled, the gate SpoolId will -# be used to load filament details and color from the spoolman database and Happy Hare will activate/deactivate -# spools as they are used. The enabled variation allows for either the local map or the spoolman map to be the -# source of truth as well as just fetching filament attributes. See this table for explanation: -# -# | Activate/ | Fetch filament attributes | Filament gate | Filament gate | -# spoolman_support | Deactivate | attributes from spoolman | assignment shown | assignment pulled | -# | spool? | based on spool_id? | in spoolman db? | from spoolman db? | -# -----------------+------------+---------------------------+------------------+-------------------+ -# off | no | no | no | no | -# readonly | yes | yes | no | no | -# push | yes | yes | yes | no | -# pull | yes | yes | yes | yes | -# -spoolman_support: off # off = disabled, readonly = enabled, push = local gate map, pull = remote gate map -pending_spool_id_timeout: 20 # Seconds after which this pending spool_id (set with rfid) is voided -# -# Mainsail/Fluid UI can visualize the color of filaments next to the extruder/tool chooser. The color is dynamic and -# can be customized to your choice: -# -# slicer - Color from slicer tool map (what the slicer expects) -# allgates - Color from all the tools in the gate map after running through the TTG map -# gatemap - As per gatemap but hide empty tools -# off - Turns off support -# -# Note: Happy Hare will also add the 'spool_id' variable to the Tx macro if spoolman is enabled -# -t_macro_color: slicer # 'slicer' = default | 'allgates' = mmu | 'gatemap' = mmu without empty gates | 'off' - - -# Print Statistics --------------------------------------------------------------------------------------------------- -# ███████╗████████╗ █████╗ ████████╗███████╗ -# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ -# ███████╗ ██║ ███████║ ██║ ███████╗ -# ╚════██║ ██║ ██╔══██║ ██║ ╚════██║ -# ███████║ ██║ ██║ ██║ ██║ ███████║ -# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ -# -# These parameters determine how print statistic data is shown in the console. This table can show a lot of data, -# probably more than you'd want to see. Below you can enable/disable options to your needs. -# -# +-----------+---------------------+----------------------+----------+ -# | 114(46) | unloading | loading | complete | -# | swaps | pre | - | post | pre | - | post | swap | -# +-----------+------+-------+------+------+-------+-------+----------+ -# | all time | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | -# | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | -# | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | -# | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | -# | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | -# +-----------+------+-------+------+------+-------+-------+----------+ -# Note: Only formats correctly on Python3 -# -# Comma separated list of desired columns -# Options: pre_unload, form_tip, unload, post_unload, pre_load, load, purge, post_load, total -console_stat_columns: unload, load, post_load, total - -# Comma separated list of rows. The order determines the order in which they're shown. -# Options: total, total_average, job, job_average, last -console_stat_rows: total, total_average, job, job_average, last - -# How you'd want to see the state of the gates and how they're performing -# string - poor, good, perfect, etc.. -# percentage - rate of success -# emoticon - fun sad to happy faces (python3 only) -console_gate_stat: emoticon - -# Always display the full statistics table -console_always_output_full: 1 # 1 = Show full table, 0 = Only show totals out of print - - -# Calibration and autotune ------------------------------------------------------------------------------------------- -# ██████╗ █████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ -# ██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ -# ██║ ███████║██║ ██║██████╔╝██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ -# ██║ ██╔══██║██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ -# ╚██████╗██║ ██║███████╗██║██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ -# ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ -# -# These are auto calibration/tuning settings that can be used to ease initial setup and/or to tune calibration over -# time based on measured telemetry. Whether these auto-tuning features are available depends on MMU design and -# configured sensors (explained below). The setting will be ignored if the required sensors are not available but if -# they can operate they will suppress the normal calibration warnings (MMU_STATUS can still be used to view them). -# Note that these are initially set by the installer to recommended values -# -# autocal_bowden_length - the calibrated bowden length will be established on first load. It can also be set -# manually or reset with MMU_CALIBRATE_BOWDEN. Best results require the use of -# sync-feedback-compression or extruder sensor but gear-touch or encoder will also work. -# 'extruder_homing_endstop' cannot be 'none' -# autotune_bowden_length - Once calibrated this setting will tune the bowden distance over time. Works best with -# toolhead sensor -# skip_cal_rotation_distance - This will rely on installed default value (although it can still be calibrated). Usually -# a good choice if autotune is enabled -# autotune_rotation_distance - Requires sync-feedback sensor (aka "buffer") or calibrated encoder. If set then either the -# "autotuner" (sync-feedback buffer) or encoder telemetry will be used to adjust the -# persisted gear rotation distance. -# skip_cal_encoder - Will rely on installed default value (although it can still be calibrated). -# Not recommended but allows for easier initial setup especially when 'autotune_encoder' -# is enabled. -# autotune_encoder - NOT IMPLEMENTED YET. Soon! -# -autocal_bowden_length: 1 # Automated bowden length calibration. 1=automatic, 0=manual/off -autotune_bowden_length: 1 # Automated bowden length tuning. 1=on, 0=off -skip_cal_rotation_distance: 0 # Skip rotation distance calibration (MMU_CALIBRATE_GEAR), 1=skip, 0=require -autotune_rotation_distance: 0 # Automated gate calibration/tuning. 1=automatic, 0=manual/off -skip_cal_encoder: 0 # Skip encoder calibration (MMU_CALIBRATE_ENCODER), 1=skip, 0=require -autotune_encoder: 0 # Automated encoder tuning. 1=automatic, 0=manual/off - - -# Miscellaneous, but you should review ------------------------------------------------------------------------------- -# ███╗ ███╗██╗███████╗ ██████╗ -# ████╗ ████║██║██╔════╝██╔════╝ -# ██╔████╔██║██║███████╗██║ -# ██║╚██╔╝██║██║╚════██║██║ -# ██║ ╚═╝ ██║██║███████║╚██████╗ -# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ -# -# Important you verify these work for you setup/workflow. Temperature and timeouts -# -timeout_pause: 72000 # Idle time out (printer shuts down) in seconds used when in MMU pause state -disable_heater: 600 # Delay in seconds after which the hotend heater is disabled in the MMU_PAUSE state -default_extruder_temp: 200 # Default temperature for performing swaps and forming tips when not in print (overridden by gate map) -extruder_temp_variance: 2 # When waiting for extruder temperature this is the +/- permissible variance in degrees (>= 1) -# -# Other workflow options -# -startup_home_if_unloaded: 0 # 1 = force mmu homing on startup if unloaded, 0 = do nothing -startup_reset_ttg_map: 0 # 1 = reset TTG map on startup, 0 = do nothing -show_error_dialog: 1 # 1 = show pop-up dialog in addition to console message, 0 = show error in console -preload_attempts: 5 # How many "grabbing" attempts are made to pick up the filament with preload feature -strict_filament_recovery: 0 # If enabled with MMU with toolhead sensor, this will cause filament position recovery to - # perform extra moves to look for filament trapped in the space after extruder but before sensor -filament_recovery_on_pause: 1 # 1 = Run a quick check to determine current filament position on pause/error, 0 = disable -retry_tool_change_on_error: 0 # Whether to automatically retry a failed tool change. If enabled Happy Hare will perform - # the equivalent of 'MMU_RECOVER' + 'Tx' commands which usually is all that is necessary - # to recover. Note that enabling this can mask problems with your MMU -bypass_autoload: 1 # If extruder sensor fitted this controls the automatic loading of extruder for bypass operation -has_filament_buffer: 1 # Whether the MMU has a filament buffer. Set to 0 if using Filamentalist or DC eSpooler, etc -# -# Advanced options. Don't mess unless you fully understand. Read documentation. -# -encoder_move_validation: 1 # ADVANCED: 1 = Normally Encoder validates move distances are within given tolerance - # 0 = Validation is disabled (eliminates slight pause between moves but less safe) -print_start_detection: 1 # ADVANCED: Enabled for Happy Hare to automatically detect start and end of print and call - # ADVANCED: MMU_PRINT_START and MMU_PRINT_END automatically. Harmless to leave enabled but can disable - # if you think it is causing problems and known START/END is covered in your macros -extruder: extruder # ADVANCED: Name of the toolhead extruder that MMU is using -gcode_load_sequence: 0 # VERY ADVANCED: Gcode loading sequence 1=enabled, 0=internal logic (default) -gcode_unload_sequence: 0 # VERY ADVANCED: Gcode unloading sequence, 1=enabled, 0=internal logic (default) - - -# ADVANCED: Klipper tuning ------------------------------------------------------------------------------------------- -# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ -# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ -# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ -# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ -# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -# Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing -# operations especially so with CANbus connected MCUs. Happy Hare uses many homing moves for reliable extruder loading -# and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error -# -update_trsync: 0 # 1 = Increase TRSYNC_TIMEOUT, 0 = Leave the klipper default -# -# Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms timeout will kill -# the print. Since it seems to occur only on homing moves they can be safely retried to workaround. This has been -# working well in practice -canbus_comms_retries: 3 # Number of retries. Recommend the default of 3. -# -# Older neopixels have very finicky timing and can generate lots of "Unable to obtain 'neopixel_result' response" -# errors in klippy.log. An often cited workaround is to increase BIT_MAX_TIME in neopixel.py. This option does that -# automatically for you to save dirtying klipper -update_bit_max_time: 1 # 1 = Increase BIT_MAX_TIME, 0 = Leave the klipper default -# -# BTT ViViD used a AHT30 sensor. If you are using an older klipper you may not have this sensor available. If so, use -# AHT10 and set this to 1 to convert AHT30 commands to AHT10 -update_aht10_commands: 0 # 1 = Config AHT10 for BTT ViViD heater sensor on older klipper, 0 = Leave the klipper default - - -# ADVANCED: MMU macro overrides --- ONLY SET IF YOU'RE COMFORTABLE WITH KLIPPER MACROS ------------------------------- -# ███╗ ███╗ █████╗ ██████╗██████╗ ██████╗ ███████╗ -# ████╗ ████║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔════╝ -# ██╔████╔██║███████║██║ ██████╔╝██║ ██║███████╗ -# ██║╚██╔╝██║██╔══██║██║ ██╔══██╗██║ ██║╚════██║ -# ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║╚██████╔╝███████║ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ -# -# 'pause_macro' defines what macro to call on MMU error (must put printer in paused state) -# Other macros are detailed in 'mmu_sequence.cfg' -# Also see form_tip_macro in Tip Forming section and purge_macro in Purging section -# -pause_macro: PAUSE # What macro to call to pause the print -action_changed_macro: _MMU_ACTION_CHANGED # Called when action (printer.mmu.action) changes -print_state_changed_macro: _MMU_PRINT_STATE_CHANGED # Called when print state (printer.mmu.print_state) changes -mmu_event_macro: _MMU_EVENT # Called on useful MMU events -pre_unload_macro: _MMU_PRE_UNLOAD # Called before starting the unload -post_form_tip_macro: _MMU_POST_FORM_TIP # Called immediately after tip forming -post_unload_macro: _MMU_POST_UNLOAD # Called after unload completes -pre_load_macro: _MMU_PRE_LOAD # Called before starting the load -post_load_macro: _MMU_POST_LOAD # Called after the load is complete -unload_sequence_macro: _MMU_UNLOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_unload_sequence' -load_sequence_macro: _MMU_LOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_load_sequence' - - -# ADVANCED: See documentation for use of these ----------------------------------------------------------------------- -# ██████╗ ███████╗███████╗███████╗████████╗ ██████╗ ███████╗███████╗███████╗ -# ██╔══██╗██╔════╝██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██╔════╝ -# ██████╔╝█████╗ ███████╗█████╗ ██║ ██║ ██║█████╗ █████╗ ███████╗ -# ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║ ██║ ██║██╔══╝ ██╔══╝ ╚════██║ -# ██║ ██║███████╗███████║███████╗ ██║ ██████╔╝███████╗██║ ███████║ -# ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ -# -# These are the values that the various "RESET" commands will reset too rather than the built-in defaults. The lenght -# of the lists must match the number of gates on your MMU -# -# e.g. MMU_GATE_MAP RESET=1 - will use all the 'gate_XXX' values -# MMU_TTG_MAP RESET=1 - will use the 'tool_to_gate_map' -# MMU_ENDLESS_SPOOL_GROUPS RESET=1 - will use the 'endless_spool_groups' -# -# Gate: #0 #1 #2 #3 #4 #5 #6 #7 #8 -#gate_status: 1, 0, 1, 2, 2, -1, -1, 0, 1 -#gate_filament_name: one, two, three, four, five, six, seven, eight, nine -#gate_material: PLA, ABS, ABS, ABS+, PLA, PLA, PETG, TPU, ABS -#gate_color: red, black, yellow, green, blue, indigo, ffffff, grey, black -#gate_temperature: 210, 240, 235, 245, 210, 200, 215, 240, 240 -#gate_spool_id: 3, 2, 1, 4, 5, 6, 7, -1, 9 -#gate_speed_override: 100, 100, 100, 100, 100, 100, 100, 50, 100 -#endless_spool_groups: 0, 1, 2, 1, 0, 0, 3, 4, 1 -# -# Tool: T0 T1 T2 T3 T4 T5 T6 T7 T8 -#tool_to_gate_map: 0, 1, 2, 3, 4, 5, 6, 7, 8 - - -# ADVANCED/CUSTOM MMU: See documentation for use of these ------------------------------------------------------------ -# ██████╗██╗ ██╗███████╗████████╗ ██████╗ ███╗ ███╗ ███╗ ███╗███╗ ███╗██╗ ██╗ -# ██╔════╝██║ ██║██╔════╝╚══██╔══╝██╔═══██╗████╗ ████║ ████╗ ████║████╗ ████║██║ ██║ -# ██║ ██║ ██║███████╗ ██║ ██║ ██║██╔████╔██║ ██╔████╔██║██╔████╔██║██║ ██║ -# ██║ ██║ ██║╚════██║ ██║ ██║ ██║██║╚██╔╝██║ ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ -# ╚██████╗╚██████╔╝███████║ ██║ ╚██████╔╝██║ ╚═╝ ██║ ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ -# ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ -# -# Normally all these settings are set based on your choice of 'mmu_vendor' and 'mmu_version' in mmu_hardware.cfg, but they -# can be overridden. If you have selected a vendor of "Other" and your MMU has a selector you must set these CAD based -# dimensions else you will get arbitrary defaults. You may also need to set additional attributes in '[mmu_machine]' -# section of mmu_hardware.cfg. -# -#cad_gate0_pos: 4.2 # Approximate distance from endstop to first gate. Used for rough calibration only -#cad_gate_width: 21.0 # Width of each gate -#cad_bypass_offset: 0 # Distance from limit of travel back to the bypass (e.g. ERCF v2.0) -#cad_last_gate_offset: 2.0 # Distance from limit of travel back to last gate -#cad_selector_tolerance: 10.0 # How much extra selector movement to allow for calibration -#cad_gate_directions = [1, 1, 0, 0] # Directions of gear depending on gate (3DChameleon) -#cad_release_gates = [2, 3, 0, 1] # Gate to move to when releasing filament (3DChameleon) diff --git a/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.rs b/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.rs deleted file mode 100644 index 37f4b17f6122..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.rs +++ /dev/null @@ -1,795 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# Template file for MMU's with Selector Stepper but no servo (Type-A designs like 3DChameleon) -# This file omits servo parts of the configuration -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Main configuration parameters for the klipper module -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# Notes: -# Macro configuration is specified separately in 'mmu_macro_vars.cfg'. -# Full details in https://github.com/moggieuk/Happy-Hare/tree/main/doc/configuration.md -# -[mmu] -happy_hare_version: {happy_hare_version} # Don't mess, used for upgrade detection - -# MMU Hardware Limits -------------------------------------------------------------------------------------------------- -# ██╗ ██╗███╗ ███╗██╗████████╗███████╗ -# ██║ ██║████╗ ████║██║╚══██╔══╝██╔════╝ -# ██║ ██║██╔████╔██║██║ ██║ ███████╗ -# ██║ ██║██║╚██╔╝██║██║ ██║ ╚════██║ -# ███████╗██║██║ ╚═╝ ██║██║ ██║ ███████║ -# ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ -# -# Define the physical limits of your MMU. These settings will be respected regardless of individual speed settings. -# -gear_max_velocity: 300 # Never to be exceeded gear velocity regardless of specific parameters -gear_max_accel: 1500 # Never to be exceeded gear acceleration regardless of specific parameters -selector_max_velocity: 250 # Never to be exceeded selector velocity regardless of specific parameters -selector_max_accel: 1200 # Never to be exceeded selector acceleration regardless of specific parameters - - -# Logging -------------------------------------------------------------------------------------------------------------- -# ██╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ -# ██║ ██╔═══██╗██╔════╝ ██╔════╝ ██║████╗ ██║██╔════╝ -# ██║ ██║ ██║██║ ███╗██║ ███╗██║██╔██╗ ██║██║ ███╗ -# ██║ ██║ ██║██║ ██║██║ ██║██║██║╚██╗██║██║ ██║ -# ███████╗╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝ -# ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# log_level & logfile_level can be set to one of (0 = essential, 1 = info, 2 = debug, 3 = trace, 4 = stepper moves) -# Generally you can keep console logging to a minimal whilst still sending debug output to the mmu.log file -# Increasing the console log level is only really useful during initial setup to save having to constantly open the log file -# Note: that it is not recommended to keep logging at level greater that 2 (debug) if not debugging an issue because -# of the additional overhead -# -log_level: 1 -log_file_level: 2 # Can also be set to -1 to disable log file completely -log_statistics: 1 # 1 to log statistics on every toolchange (default), 0 to disable (but still recorded) -log_visual: 1 # 1 log visual representation of filament, 0 = disable -log_startup_status: 1 # Whether to log tool to gate status on startup, 1 = summary (default), 0 = disable -log_m117_messages: 1 # Whether send toolchange message via M117 to screen - - -# Movement speeds ------------------------------------------------------------------------------------------------------ -# ███████╗██████╗ ███████╗███████╗██████╗ ███████╗ -# ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝ -# ███████╗██████╔╝█████╗ █████╗ ██║ ██║███████╗ -# ╚════██║██╔═══╝ ██╔══╝ ██╔══╝ ██║ ██║╚════██║ -# ███████║██║ ███████╗███████╗██████╔╝███████║ -# ╚══════╝╚═╝ ╚══════╝╚══════╝╚═════╝ ╚══════╝ -# -# Long moves are faster than the small ones and used for the bulk of the bowden movement. You can set two fast load speeds -# depending on whether pulling from the spool or filament buffer (if fitted and not the first time load). This can be helpful -# in allowing faster loading from buffer and slower when pulling from the spool because of the additional friction (prevents -# loosing steps). Unloading speed can be tuning if you have a rewinder system that imposes additional limits. -# NOTE: Encoder cannot keep up much above 450mm/s so make sure 'bowden_apply_correction' is off at very high speeds! -# -gear_from_spool_speed: 80 # mm/s Speed when loading from the spool (for the first time if has_filament_buffer: 1) -gear_from_spool_accel: 100 # Acceleration when loading from spool -gear_from_buffer_speed: 150 # mm/s Speed when loading filament from buffer. Conservative is 100mm/s, Max around 400mm/s -gear_from_buffer_accel: 400 # Normal acceleration when loading filament -gear_unload_speed: 80 # mm/s Use (lower) speed when unloading filament (defaults to "from spool" speed) -gear_unload_accel: 100 # Acceleration when unloading filament (defaults to "from spool" accel) -# -gear_short_move_speed: 80 # mm/s Speed when making short moves (like incremental retracts with encoder) -gear_short_move_accel: 600 # Usually the same as gear_from_buffer_accel (for short movements) -gear_short_move_threshold: 70 # Move distance that controls application of 'short_move' speed/accel -gear_homing_speed: 50 # mm/s Speed of gear stepper only homing moves (e.g. homing to gate or extruder) - -# Speeds of extruder movement. The 'sync' speeds will be used when gear and extruder steppers are moving in sync -# -extruder_load_speed: 16 # mm/s speed of load move inside extruder from homing position to meltzone -extruder_unload_speed: 16 # mm/s speed of unload moves inside of extruder (very initial move from meltzone is 50% of this) -extruder_sync_load_speed: 18 # mm/s speed of synchronized extruder load moves -extruder_sync_unload_speed: 18 # mm/s speed of synchronized extruder unload moves -extruder_homing_speed: 18 # mm/s speed of extruder only homing moves (e.g. to toolhead sensor) - -# Selector movement speeds. (Acceleration is defined by physical MMU limits set above and passed to selector stepper driver) -# -selector_move_speed: 200 # mm/s speed of selector movement (not touch) -selector_homing_speed: 60 # mm/s speed of initial selector homing move (not touch) -selector_touch_speed: 80 # mm/s speed of all touch selector moves (if stallguard configured) - -# Selector touch (stallguard) operation. If stallguard is configured, then this can be used to switch on touch movement which -# can detect blocked filament path and try to recover automatically but it is more difficult to set up -# -selector_touch_enabled: 0 # If selector touch operation configured this can be used to disable it 1=enabled, 0=disabled - -# When Happy Hare calls out to a macro for user customization and for parking moves these settings are applied and the previous -# values automatically restored afterwards. This allows for deterministic movement speed regardless of the starting state. -# -macro_toolhead_max_accel: 0 # Default printer toolhead acceleration applied when macros are run. 0 = use printer max -macro_toolhead_min_cruise_ratio: 0.5 # Default printer cruise ratio applied when macros are run - - -# Gate loading/unloading ----------------------------------------------------------------------------------------------- -# ██████╗ █████╗ ████████╗███████╗ ██╗ ██████╗ █████╗ ██████╗ -# ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██║ ███╗███████║ ██║ █████╗ ██║ ██║ ██║███████║██║ ██║ -# ██║ ██║██╔══██║ ██║ ██╔══╝ ██║ ██║ ██║██╔══██║██║ ██║ -# ╚██████╔╝██║ ██║ ██║ ███████╗ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -# These settings control the loading and unloading filament at the gate which is the parking position inside the MMU. -# Typically this would be switch sensor but you can also use an encoder. Even with encoder the endstop can be a switch -# and the encoder used for move verifcation (see advanced 'gate_endstop_to_encoder' option). Note that the `encoder` -# method, due to the nature of its operation will overshoot a little. This is not a problem in practice because the -# overshoot will simply be compensated for in the subsequent move. A +ve parking distance moves towards the MMU, -ve -# moves back through the endstop towards the toolhead. If the MMU has multiple bowden tubes then it is possible to home -# at the extruder sensor and avoid long bowden moves! -# -# Possible gate_homing_endstop names: -# encoder - Detect filament position using movement of the encoder -# mmu_gate - Use gate endstop -# mmu_gear - Use individual per-gate endstop (type-B MMU's) -# extruder - Use extruder entry sensor (Only for some type-B designs, see [mmu_machine] require_bowden_move setting) -# -gate_homing_endstop: encoder # Name of gate endstop, "encoder" forces use of encoder for parking -gate_homing_max: 70 # Maximum move distance to home to the gate (or actual move distance for encoder parking) -gate_preload_homing_max: 70 # Maximum homing distance to the mmu_gear endstop (if MMU is fitted with one) -gate_preload_parking_distance: 0 # Parking position relative to mmu_gear endstop (-ve value means move forward) -gate_unload_buffer: 50 # Amount to reduce the fast unload so that filament doesn't overshoot when parking -gate_load_retries: 2 # Number of times MMU will attempt to grab the filament on initial load (type-A designs) -gate_parking_distance: 23 # Parking position in the gate (distance back from homing point, -ve value means move forward) -gate_endstop_to_encoder: 10 # Distance between gate endstop and encoder (IF both fitted. +ve if encoder after endstop) -gate_autoload: 1 # If pre-gate sensor fitted this controls the automatic loading of the gate -gate_final_eject_distance: 0 # Distance to eject filament on MMU_EJECT (Ignored by MMU_UNLOAD) - - -# Bowden tube loading/unloading ---------------------------------------------------------------------------------------- -# ██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ -# ██╔══██╗██╔═══██╗██║ ██║██╔══██╗██╔════╝████╗ ██║ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██████╔╝██║ ██║██║ █╗ ██║██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║███████║██║ ██║ -# ██╔══██╗██║ ██║██║███╗██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██║██║ ██║ -# ██████╔╝╚██████╔╝╚███╔███╔╝██████╔╝███████╗██║ ╚████║ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -bowden_homing_max: 2000 # Maximum attempted bowden move (for calibration). Should be larger than your actual bowden! - -# If you MMU is equiped with an encoder the following options are available: -# -# In addition to different bowden loading speeds for buffer and non-buffered filament it is possible to detect missed -# steps caused by "jerking" on a heavy spool. If bowden correction is enabled Happy Hare will "believe" the encoder -# reading and make correction moves to bring the filament to within the 'bowden_allowable_load_delta' of the end of -# bowden position (this does require a reliable encoder and is not recommended for very high speed loading >350mm/s) -# -bowden_apply_correction: 0 # 1 to enable, 0 disabled -bowden_allowable_load_delta: 20.0 # How close in mm the correction moves will attempt to get to target -# -# This saftey check uses the encoder to verify the filament is free of extruder before the fast bowden movement to -# reduce possibility of grinding filament. If enabled the trigger can be tuned by setting the "error tolerance" which -# represents the fraction of allowable mismatch between actual movement and that seen by encoder. Setting to 50% tolerance -# usually works well. Increasing will make test more tolerant. Value of 100% essentially disables error detection -# -bowden_pre_unload_test: 1 # 1 to check for bowden movement before full pull (slower), 0 don't check (faster) -bowden_pre_unload_error_tolerance: 50 # ADVANCED: tune pre_unload_test - - -# Extruder homing ----------------------------------------------------------------------------------------------------- -# ███████╗██╗ ██╗████████╗ ██╗ ██╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ -# ██╔════╝╚██╗██╔╝╚══██╔══╝ ██║ ██║██╔═══██╗████╗ ████║██║████╗ ██║██╔════╝ -# █████╗ ╚███╔╝ ██║ ███████║██║ ██║██╔████╔██║██║██╔██╗ ██║██║ ███╗ -# ██╔══╝ ██╔██╗ ██║ ██╔══██║██║ ██║██║╚██╔╝██║██║██║╚██╗██║██║ ██║ -# ███████╗██╔╝ ██╗ ██║██╗ ██║ ██║╚██████╔╝██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ -# ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# Happy Hare needs a reference "homing point" close to the extruder from which to accurately complete the loading of -# the toolhead. This homing operation takes place after the fast bowden load and it is anticipated that that load -# operation will leave the filament just shy of the homing point. If using a toolhead sensor this initial extruder -# homing is unnecessary (but can be forced) because the homing will occur inside the extruder for the optimum in accuracy. -# You still should set this homing method because it is also used for the determination and calibration of bowden length. -# -# In addition to an entry sensor "extruder" it is possible for Happy Hare to "feel" for the extruder gear entry -# by colliding with it. This can be done with encoder based collision detection, the compression of the sync-feedback -# (aka buffer) sensor or using "touch" (stallguard) on the gear stepper. Note that encoder collision detection is not -# completely deterministic and you will have to find the sweetspot for your setup by adjusting the TMC current reduction. -# Note that reduced current during collision detection can also prevent unecessary filament griding. -# -# Possible extruder_homing_endtop names: -# collision - Detect the collision with the extruder gear by monitoring encoder movement (Requires encoder) -# Fast bowden load will move to the extruder gears -# mmu_gear_touch - Use touch detection when the gear stepper hits the extruder (Requires stallguard) -# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home -# extruder - If you have a "filament entry" endstop configured (Requires 'extruder' endstop) -# Fast bowden load will move to extruder_homing_buffer distance before sensor, then home -# filament_compression - If you have a "sync-feedback" sensor with compression switch configured -# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home -# none - Don't attempt to home. Only possibiliy if lacking all sensor options -# Fast bowden load will move to the extruder gears. Option is fine if using toolhead sensor -# Note: The homing_endstop will be ignored ("none") if a toolhead sensor is available unless "extruder_force_homing: 1" -# -extruder_homing_max: 80 # Maximum distance to advance in order to attempt to home the extruder -extruder_homing_endstop: collision # Filament homing method/endstop name (fallback if toolhead sensor not available) -extruder_homing_buffer: 25 # Amount to reduce the fast bowden load so filament doesn't overshoot the extruder homing point -extruder_collision_homing_current: 30 # % gear_stepper current (10%-100%) to use when homing to extruder homing (100 to disable) - -# If you have a toolhead sensor it will always be used as a homing point making the homing outside of the extruder -# potentially unnecessary. However you can still force this initial homing step by setting this option in which case -# the filament will home to the extruder and then home to the toolhead sensor in two steps -# -extruder_force_homing: 0 - - -# Toolhead loading and unloading -------------------------------------------------------------------------------------- -# ████████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗███████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ -# ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██║ ██║ ██║██║ ██║██║ ███████║█████╗ ███████║██║ ██║ ██║ ██║ ██║███████║██║ ██║ -# ██║ ██║ ██║██║ ██║██║ ██╔══██║██╔══╝ ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║ ██║ -# ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║███████╗██║ ██║██████╔╝ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -# It is possible to define highly customized loading and unloading sequences, however, unless you have a specialized -# setup it is probably easier to opt for the built-in toolhead loading and unloading sequence which already offers a -# high degree of customization. If you need even more control then edit the _MMU_LOAD_SEQUENCE and _MMU_UNLOAD_SEQUENCE -# macros in mmu_sequence.cfg - but be careful! -# -# An MMU must have a known point at the end of the bowden from which it can precisely load the extruder. Generally this -# will either be the extruder entrance (which is controlled with settings above) or by homing to toolhead sensor. If -# you have toolhead sensor it is past the extruder gear and the driver needs to know the max distance (from end of -# bowden move) to attempt homing -# -toolhead_homing_max: 40 # Maximum distance to advance in order to attempt to home to defined homing endstop - -# IMPORTANT: These next three settings are based on the physical dimensions of your toolhead -# Once a homing position is determined, Happy Hare needs to know the final move distance to the nozzle. There is only -# one correct value for your setup - use 'toolhead_ooze_reduction' (which corresponds to the residual filament left in -# your nozzle) to control excessive oozing on load. See doc for table of proposed values for common configurations. -# -# NOTE: If you have a toolhead sensor you can automate the calculation of these parameters! Read about the -# `MMU_CALIBRATE_TOOLHEAD` command (https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing#---calibrating-toolhead) -# -toolhead_extruder_to_nozzle: 72 # Distance from extruder gears (entrance) to nozzle -toolhead_sensor_to_nozzle: 62 # Distance from toolhead sensor to nozzle (ignored if not fitted) -toolhead_entry_to_extruder: 8 # Distance from extruder "entry" sensor to extruder gears (ignored if not fitted) - -# This setting represents how much residual filament is left behind in the nozzle when filament is removed, it is thus -# used to reduce the extruder loading length and prevent excessive blobbing but also in the calculation of purge volume. -# Note that this value can also be measured with the `MMU_CALIBRATE_TOOLHEAD` procedure -# -toolhead_residual_filament: 0 # Reduction in extruder loading length because of residual filament left behind - -# TUNING: Finally, this is the last resort tuning value to fix blobbing. It is expected that this value is NEAR ZERO as -# it represents a further reduction in extruder load length to fix blobbing. If using a wipetower and you experience blobs -# on it, increase this value (reduce the quantity of filament loaded). If you experience gaps, decrease this value. If gaps -# and already at 0 then perhaps the 'toolhead_extruder_to_nozzle' or 'toolhead_residual_filament' settings are incorrect. -# Similarly a value >+5mm also suggests the four settings above are not correct. Also see 'retract' setting in -# 'mmu_macro_vars.cfg' for final in-print ooze tuning. -# -toolhead_ooze_reduction: 0 # Reduction in extruder loading length to prevent ooze (represents filament remaining) - -# Distance added to the extruder unload movement to ensure filament is free of extruder. This adds some degree of tolerance -# to slightly incorrect configuration or extruder slippage. However don't use as an excuse for incorrect toolhead settings -# -toolhead_unload_safety_margin: 10 # Extra movement safety margin (default: 10mm) - -# If not synchronizing gear and extruder and you experience a "false" clog detection immediately after the tool change -# it might be because of a long bowden and/or large internal diameter that causes slack in the filament. This optional -# move will tighten the filament after a load by % of current clog detection length. Gear stepper will run at 50% current -# -toolhead_post_load_tighten: 60 # % of clog detection length, 0 to disable. Ignored if 'sync_to_extruder: 1' - -# If synchronizing gear and extruder and you have a sync-feedback "buffer" this setting determines whether to use it -# to create neutral tension after loading -toolhead_post_load_tension_adjust: 1 # 1 to enable (recommended), 0 to disable - -# If sync-feedback compression sensor is available this test will ensure the filament passes the extruder entry by checking -# for neutral tension when moving filament with just the extruder. Recommended with sprung loaded sync-feedback buffers. -# This is ignored if toolhead sensor is available. -toolhead_entry_tension_test: 1 # 1 to enable (recommended), 0 to disable - -# ADVANCED: Controls the detection of successful extruder load/unload movement and represents the fraction of allowable -# mismatch between actual movement and that seen by encoder. Setting to 100% tolerance effectively turns off checking. -# Some designs of extruder have a short move distance that may not be picked up by encoder and cause false errors. This -# allows masking of those errors. However the error often indicates that your extruder load speed is too high or the -# friction is too high on the filament and in that case masking the error is not a good idea. Try reducing friction -# and lowering speed first! -# -toolhead_move_error_tolerance: 60 - - -# Tip forming --------------------------------------------------------------------------------------------------------- -# ████████╗██╗██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ -# ╚══██╔══╝██║██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ -# ██║ ██║██████╔╝ █████╗ ██║ ██║██████╔╝██╔████╔██║██║██╔██╗ ██║██║ ███╗ -# ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██║ ██║ -# ██║ ██║██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# Tip forming responsibility can be split between slicer (in-print) and standalone macro (not in-print) or forced to always -# be done by Happy Hare's standalone macro. Since you always need the option to form tips without the slicer so it is -# generally easier to completely turn off the slicer, force "standalone" tip forming and tune only in Happy Hare. -# -# When Happy Hare is asked to form a tip it will run the referenced macro. Two are reference examples are provided but -# you can implement your own: -# _MMU_FORM_TIP .. default tip forming similar to popular slicers like Superslicer and Prusaslicer -# _MMU_CUT_TIP .. for Filametrix (originally ERCFv2) or similar style toolhead filament cutting system -# -# NOTE: For MMU located cutting like the optional EREC cutter you should set still this to _MMU_FORM_TIP to build a decent -# tip prior to extraction and cutting after the unload. -# -# Often it is useful to increase the extruder current for the rapid movement to ensure high torque and no skipped steps -# -# If opting for slicer tip forming you MUST configure where the slicer leaves the filament in the extruder since -# there is no way to determine this. This can be ignored if all tip forming is performed by Happy Hare -# -force_form_tip_standalone: 1 # 0 = Slicer in print else standalone, 1 = Always standalone tip forming (TURN SLICER OFF!) -form_tip_macro: _MMU_FORM_TIP # Name of macro to call to perform the tip forming (or cutting) operation -extruder_form_tip_current: 100 # % of extruder current (100%-150%) to use when forming tip (100 to disable) -slicer_tip_park_pos: 0 # This specifies the position of filament in extruder after slicer completes tip forming - - -# Purging ------------------------------------------------------------------------------------------------------------- -# ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ -# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██║████╗ ██║██╔════╝ -# ██████╔╝██║ ██║██████╔╝██║ ███╗██║██╔██╗ ██║██║ ███╗ -# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║ -# ██║ ╚██████╔╝██║ ██║╚██████╔╝██║██║ ╚████║╚██████╔╝ -# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# After a toolchange it is necessary to purge the old filament. Similar to tip forming this can be done by the slicer and/or -# by Happy Hare using an extension like Blobifer. If a purge_macro is defined it will be called when not printing or whenever -# the slicer isn't going to purge (like initial tool load). You can force it to always be called in a print by setting -# force_purge_standalone, but remember to turn off the slicer wipetower -# -# The default is for no (empty) macro so purging will not be done out of a print and thus wipetower. Two options are shipped with -# Happy Hare but you can also build your own custom one: -# _MMU_PURGE .. default purging that just dumps the desired amount of filament (setup correct parking before enabling this!) -# BLOBIFER .. for excellent Blobifer addon (https://github.com/Dendrowen/Blobifier) -# -# Often it is useful to increase the extruder current for the often rapid puring movement to ensure high torque and no skipped steps -# -force_purge_standalone: 0 # 0 = Slicer wipetower in print else standalone, 1 = Always standalone purging (TURN WIPETOWER OFF!) -purge_macro: _MMU_PURGE # Name of macro to call to perform the standalone purging operation. E.g. BLOBIFIER, _MMU_PURGE -extruder_purge_current: 100 # % of extruder current (100%-150%) to use when purging (100 to disable) - - -# Synchronized gear/extruder movement ---------------------------------------------------------------------------------- -# ███╗ ███╗ ██████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗███╗ ██╗ ██████╗ -# ████╗ ████║██╔═══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██╔════╝╚██╗ ██╔╝████╗ ██║██╔════╝ -# ██╔████╔██║██║ ██║ ██║ ██║ ██║██████╔╝ ███████╗ ╚████╔╝ ██╔██╗ ██║██║ -# ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║██╔══██╗ ╚════██║ ╚██╔╝ ██║╚██╗██║██║ -# ██║ ╚═╝ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║ ███████║ ██║ ██║ ╚████║╚██████╗ -# ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ -# -# This controls whether the extruder and gear steppers are synchronized during printing operations -# If you normally run with maxed out gear stepper current consider reducing it with 'sync_gear_current' -# If equipped with TMC drivers the current of the gear and extruder motors can be controlled to optimize performance. -# This can be useful to control gear stepper temperature when printing with synchronized motor -# -sync_to_extruder: 0 # Gear motor is synchronized to extruder during print -sync_gear_current: 70 # % of gear_stepper current (10%-100%) to use when syncing with extruder during print -sync_form_tip: 0 # Synchronize during standalone tip formation (initial part of unload) -sync_purge: 0 # Synchronize during standalone purging (last part of load) - -# Optionally it is possible to leverage feedback from a "compression/expansion" sensor (aka "buffer") in the bowden -# path from MMU to extruder to ensure that the two motors are kept in sync as viewed by the filament (the signal feedback -# state can be binary supplied by one or two switches: -1 (expanded) and 1 (compressed) of proportional value between -# -1.0 and 1.0. -# -# If only "one half" of the sync-feedback is available (either compression-only or tension-only) then the rotation -# distance is always shifted based on the high/low multipliers, however if both tension and compression are available -# then the rotation distance will autotune to correct setting (recommend you also enable 'autotune_rotation_distance: 1' -# Note that proportional feedback sensors are continuously dynamic -# -# Possible buffer setups, forth option for type where neutral is when both sensors are active: -# -# <------maxrange------> <------maxrange------> <------maxrange------> <------maxrange------> -# <--range---> <----range-----> <----range-----> <> range=0 -# |====================| |====================| |====================| |====================| -# ^ ^ ^ ^ ^^ -# compression tension compression-only tension-only -# -sync_feedback_enabled: 0 # Turn off even if sensor is installed and active -sync_feedback_buffer_range: 6 # Travel in "buffer" between compression/tension or one sensor and end (see above) -sync_feedback_buffer_maxrange: 12 # Absolute maximum end-to-end travel (mm) provided by buffer (see above) -sync_feedback_speed_multiplier: 5 # % "twolevel" gear speed delta to keep filament neutral in buffer (recommend 5%) -sync_feedback_boost_multiplier: 3 # % "twolevel" extra gear speed boost for finding initial neutral position (recommend 3%) -sync_feedback_extrude_threshold: 5 # Extruder movement (mm) for updates (keep small but set > retract distance) - -# If defined this forces debugging to a telemetry log file "sync_.jsonl". This is great if trying to tune clog/tangle -# detection or for getting help on the Happy Hare forum. To plot graph of sync-feedback operation, run: -# ~/Happy-Hare/utils/plot_sync_feedback.sh -# -sync_feedback_debug_log: 0 # 0 = disable (normal opertion), 1 = enable telemetry log (for debugging) - - -# ESpooler control ----------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ -# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ -# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ -# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ -# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ -# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ -# -# If your MMU has a dc motor (often N20) controlled respooler/assist then how it operates can be controlled with these -# settings. Typically the espooler will be controlled with PWM signal. This will be at the maximum at speeds equal or -# above 'espooler.max_stepper_speed'. The PWM signal will scale downwards towards 0 for slower speeds. The falloff being -# controlled by the 'espooler_speed_exponent' setting according to this formula and allows for non-linear characteristics -# the DC motor (0.5 is a good starting value). -# -# espooler_pwm = (stepper_speed / espooler_max_stepper_speed) ^ {espooler_speed_exponent} -# -# Regardless of h/w configuration you can enable/disable actions with the 'espooler_operations' list. E.g. remove 'play' to -# turn off operation while printing. Options are: -# -# rewind - when filament is being unloaded under MMU control (aka respool) -# assist - when filament is being loaded under MMU control (% of "rewind" speed but with minimum of "print" power) -# print - while printing. Generally set 'espooler_printing_power' to a low percentage just to allow motor to be turned -# freely or set to 0 to enable/allow "burst" assist movements -# -# If using a digitally controlled espooler motor (not PWM) then you should turn off the "print" mode and set -# 'espooler_min_stepper_speed' to prevent "over movement" -# -espooler_min_distance: 30 # Individual stepper movements less than this distance will not active espooler -espooler_max_stepper_speed: 300 # Gear stepper speed at which espooler will be at maximum power -espooler_min_stepper_speed: 0 # Gear stepper speed at which espooler will become inactive (useful for non PWM control) -espooler_speed_exponent: 0.5 # Controls non-linear espooler power relative to stepper speed (see notes) -espooler_assist_reduced_speed: 50 # Control the % of the rewind speed that is applied to assisting load (want rewind to be faster) -espooler_printing_power: 0 # If >0, fixes the % of PWM power while printing. 0=allows burst movement -espooler_operations: rewind, assist, print # List of operational modes (allows disabling even if h/w is configured) -# -# The following burst configuration is used to control the small rotation in the ASSIST direction optionally used -# when in 'print' operation is enabled, 'espooler_printing_power: 0' and is triggered (tension switch or extruder movement). -# It can also be used to loosen filament with 'MMU_ESPOOLER COMMAND=assist BURST=1' -# -espooler_assist_extruder_move_length: 100 # Distance (mm) extruder needs to move between each assist burst -espooler_assist_burst_power: 100 # The % power of the burst move -espooler_assist_burst_duration: 0.4 # The duration of the burst move is seconds -espooler_assist_burst_trigger: 0 # If trigger assist switch is fitted 0=disable, 1=enable -espooler_assist_burst_trigger_max: 3 # If trigger assist switch is fitted this limits the max number of back-to-back advances -# -# The following burst configuration is used to control the small rotation in the REWIND direction optionally used -# when running running the filament drying cycle. The goal is to rotate the spool 60-90 degrees. It can also be -# used to tighten the filament with 'MMU_ESPOOLER COMMAND=rewind BURST=1' -# -espooler_rewind_burst_power: 100 # The % power of the rewind burst move -espooler_rewind_burst_duration: 0.4 # The duration of the rewind burst move is seconds - - -# Heater / Environment Management ------------------------------------------------------------------------------------ -# ██╗ ██╗███████╗ █████╗ ████████╗███████╗██████╗ -# ██║ ██║██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ -# ███████║█████╗ ███████║ ██║ █████╗ ██████╔╝ -# ██╔══██║██╔══╝ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ -# ██║ ██║███████╗██║ ██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -heater_max_temp: 70 # Absolute max heater setting to protect the MMU enclosure construction (adjust to match material) -heater_default_dry_temp: 45 # Default drying temperature if filament type is not matched in drying_data -heater_default_dry_time: 300 # Default drying cycle time in minutes -heater_default_humidity: 25 # Default humidity % goal. Drying will terminate if this value is reached -heater_vent_macro: _MMU_VENT # Name of macro to periodicaly call during drying cycle -heater_vent_interval: 0 # Interval in minutes to call heater_vent_macro during drying cycle, 0=disable venting -heater_rotate_interval: 5 # Interval in minutes to rotate filament (requires eSpooler and filament end attached to spool) - -# Drying data for MMU_HEATER DRY=1 command in form (material type is case insensitive): -# 'filament_type': (temp, drying_time_mins) -# -# (Careful with formatting of this line - reformatting will break upgrade logic) -# -drying_data: { 'pla': (45, 300), 'pla+': (55, 300), 'petg': (60, 300), 'tpu': (55, 300), 'abs': (70, 300), 'abs+': (75, 300), 'asa': (65, 300), 'nylon': (75, 600), 'pc': (75, 600), 'pva': (75, 600), 'hips': (75, 600) } - - -# FlowGuard Clog and Tangle Detection -------------------------------------------------------------------------------- -# ███████╗██╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ -# ██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗ -# █████╗ ██║ ██║ ██║██║ █╗ ██║██║ ███╗██║ ██║███████║██████╔╝██║ ██║ -# ██╔══╝ ██║ ██║ ██║██║███╗██║██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║ -# ██║ ███████╗╚██████╔╝╚███╔███╔╝╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝ -# ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ -# -# Options are available to automatically detects extruder clogs and MMU tangles. Each option works independently and -# can be combined. Flowguard can even discern the difference between an extruder clog and a spool tangle! -# -# Flowguard: This intelligently measures filament tension (only available if sync-feedback buffer is fitted) -# -# Encoder detection: This monitors encoder movement and compares to extruder (only available if encoder is fitted) -# -flowguard_enabled: 1 # 0 = Flowguard protection disabled, 1 = Enabled - -# The flowguard_max_relief is the amount of relief movement (effective mm change in filament length between MMU and extruder) -# that Happy Hare will wait until triggering a clog or runout. A smaller value is more sensitive to triggering. Since the -# relief movement is hightly dependent on filament "spring" in the bowden tube, filament friction, and -# 'sync_feedback_buffer_range', it is generally good to start high and then decrease if a more sensitive trigger is desired. -# Analog proportional (type P) sensors can generally have a much lower value. Increase if you have false triggers. -flowguard_max_relief: 40 - -# Encoder runout/clog/tangle detection watches for movement over either a static or automatically adjusted distance - if -# no encoder movement is seen when the extruder moves this distance runout/ clog/tangle event will be generated. Allowing -# the distance to be adjusted automatically (mode=2) will generally allow for a quicker trigger but use a static length -# (mode=1, set encoder_max_motion) if you get false triggers (see flowguard guide on wiki for more details) -# Note that this feature cannot disinguish between clog or tangle. -flowguard_encoder_mode: 2 # 0 = Disable, 1 = Static length clog detection, 2 = Automatic length clog detection - -# The encoder_max_motion is the absolute max permitted extruder movement without the encoder seeing movement when using -# status mode (mode=1). Smaller values are more sensitive but beware of going too small - slack and friction in the -# bowden may cause gaps in encoder movement. Increase if you have false triggers -# Note that this value is overriden by any calibrated value stored in 'mmu_vars.cfg' if in automatic mode (mode=2). -flowguard_encoder_max_motion: 20 - - -# Filament Management Options ---------------------------------------------------------------------------------------- -# ███████╗██╗██╗ ███╗ ███╗ ██████╗ ███╗ ███╗████████╗ -# ██╔════╝██║██║ ████╗ ████║██╔════╝ ████╗ ████║╚══██╔══╝ -# █████╗ ██║██║ ██╔████╔██║██║ ███╗██╔████╔██║ ██║ -# ██╔══╝ ██║██║ ██║╚██╔╝██║██║ ██║██║╚██╔╝██║ ██║ -# ██║ ██║███████╗██╗ ██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ -# -# - EndlessSpool feature allows detection of runout on one spool and the automatic mapping of tool to an alternative -# gate (spool). Set to '1', this feature requires clog detection or gate sensor or pre-gate sensors. EndlessSpool -# functionality can optionally be extended to attempt to load an empty gate with 'endless_spool_on_load'. On some MMU -# designs (with linear selector) it can also be configured to eject filament remains to a designated gate rather than -# defaulting to current gate. A custom gate will disable pre-gate runout detection for EndlessSpool because filament -# end must completely pass through the gate for selector to move -# -endless_spool_enabled: 1 # 0 = disable, 1 = enable endless spool -endless_spool_on_load: 0 # 0 = don't apply endless spool on load, 1 = run endless spool if gate is empty -endless_spool_eject_gate: -1 # Which gate to eject the filament remains. -1 = current gate -#endless_spool_groups: # Default EndlessSpool groups (see later in file) -# -# Spoolman support requires you to correctly enable spoolman with moonraker first. If enabled, the gate SpoolId will -# be used to load filament details and color from the spoolman database and Happy Hare will activate/deactivate -# spools as they are used. The enabled variation allows for either the local map or the spoolman map to be the -# source of truth as well as just fetching filament attributes. See this table for explanation: -# -# | Activate/ | Fetch filament attributes | Filament gate | Filament gate | -# spoolman_support | Deactivate | attributes from spoolman | assignment shown | assignment pulled | -# | spool? | based on spool_id? | in spoolman db? | from spoolman db? | -# -----------------+------------+---------------------------+------------------+-------------------+ -# off | no | no | no | no | -# readonly | yes | yes | no | no | -# push | yes | yes | yes | no | -# pull | yes | yes | yes | yes | -# -spoolman_support: off # off = disabled, readonly = enabled, push = local gate map, pull = remote gate map -pending_spool_id_timeout: 20 # Seconds after which this pending spool_id (set with rfid) is voided -# -# Mainsail/Fluid UI can visualize the color of filaments next to the extruder/tool chooser. The color is dynamic and -# can be customized to your choice: -# -# slicer - Color from slicer tool map (what the slicer expects) -# allgates - Color from all the tools in the gate map after running through the TTG map -# gatemap - As per gatemap but hide empty tools -# off - Turns off support -# -# Note: Happy Hare will also add the 'spool_id' variable to the Tx macro if spoolman is enabled -# -t_macro_color: slicer # 'slicer' = default | 'allgates' = mmu | 'gatemap' = mmu without empty gates | 'off' - - -# Print Statistics --------------------------------------------------------------------------------------------------- -# ███████╗████████╗ █████╗ ████████╗███████╗ -# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ -# ███████╗ ██║ ███████║ ██║ ███████╗ -# ╚════██║ ██║ ██╔══██║ ██║ ╚════██║ -# ███████║ ██║ ██║ ██║ ██║ ███████║ -# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ -# -# These parameters determine how print statistic data is shown in the console. This table can show a lot of data, -# probably more than you'd want to see. Below you can enable/disable options to your needs. -# -# +-----------+---------------------+----------------------+----------+ -# | 114(46) | unloading | loading | complete | -# | swaps | pre | - | post | pre | - | post | swap | -# +-----------+------+-------+------+------+-------+-------+----------+ -# | all time | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | -# | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | -# | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | -# | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | -# | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | -# +-----------+------+-------+------+------+-------+-------+----------+ -# Note: Only formats correctly on Python3 -# -# Comma separated list of desired columns -# Options: pre_unload, form_tip, unload, post_unload, pre_load, load, purge, post_load, total -console_stat_columns: unload, load, post_load, total - -# Comma separated list of rows. The order determines the order in which they're shown. -# Options: total, total_average, job, job_average, last -console_stat_rows: total, total_average, job, job_average, last - -# How you'd want to see the state of the gates and how they're performing -# string - poor, good, perfect, etc.. -# percentage - rate of success -# emoticon - fun sad to happy faces (python3 only) -console_gate_stat: emoticon - -# Always display the full statistics table -console_always_output_full: 1 # 1 = Show full table, 0 = Only show totals out of print - - -# Calibration and autotune ------------------------------------------------------------------------------------------- -# ██████╗ █████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ -# ██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ -# ██║ ███████║██║ ██║██████╔╝██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ -# ██║ ██╔══██║██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ -# ╚██████╗██║ ██║███████╗██║██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ -# ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ -# -# These are auto calibration/tuning settings that can be used to ease initial setup and/or to tune calibration over -# time based on measured telemetry. Whether these auto-tuning features are available depends on MMU design and -# configured sensors (explained below). The setting will be ignored if the required sensors are not available but if -# they can operate they will suppress the normal calibration warnings (MMU_STATUS can still be used to view them). -# Note that these are initially set by the installer to recommended values -# -# autocal_bowden_length - the calibrated bowden length will be established on first load. It can also be set -# manually or reset with MMU_CALIBRATE_BOWDEN. Best results require the use of -# sync-feedback-compression or extruder sensor but gear-touch or encoder will also work. -# 'extruder_homing_endstop' cannot be 'none' -# autotune_bowden_length - Once calibrated this setting will tune the bowden distance over time. Works best with -# toolhead sensor -# skip_cal_rotation_distance - This will rely on installed default value (although it can still be calibrated). Usually -# a good choice if autotune is enabled -# autotune_rotation_distance - Requires sync-feedback sensor (aka "buffer") or calibrated encoder. If set then either the -# "autotuner" (sync-feedback buffer) or encoder telemetry will be used to adjust the -# persisted gear rotation distance. -# skip_cal_encoder - Will rely on installed default value (although it can still be calibrates). -# Not recommended but allows for easier initial setup especially when 'autotune_encoder' -# is enabled. -# autotune_encoder - NOT IMPLEMENTED YET. Soon! -# -autocal_bowden_length: 1 # Automated bowden length calibration. 1=automatic, 0=manual/off -autotune_bowden_length: 1 # Automated bowden length tuning. 1=on, 0=off -skip_cal_rotation_distance: 0 # Skip rotation distance calibration (MMU_CALIBRATE_GEAR), 1=skip, 0=require -autotune_rotation_distance: 0 # Automated gate calibration/tuning. 1=automatic, 0=manual/off -skip_cal_encoder: 0 # Skip encoder calibration (MMU_CALIBRATE_ENCODER), 1=skip, 0=require -autotune_encoder: 0 # Automated encoder tuning. 1=automatic, 0=manual/off - - -# Miscellaneous, but you should review ------------------------------------------------------------------------------- -# ███╗ ███╗██╗███████╗ ██████╗ -# ████╗ ████║██║██╔════╝██╔════╝ -# ██╔████╔██║██║███████╗██║ -# ██║╚██╔╝██║██║╚════██║██║ -# ██║ ╚═╝ ██║██║███████║╚██████╗ -# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ -# -# Important you verify these work for you setup/workflow. Temperature and timeouts -# -timeout_pause: 72000 # Idle time out (printer shuts down) in seconds used when in MMU pause state -disable_heater: 600 # Delay in seconds after which the hotend heater is disabled in the MMU_PAUSE state -default_extruder_temp: 200 # Default temperature for performing swaps and forming tips when not in print (overridden by gate map) -extruder_temp_variance: 2 # When waiting for extruder temperature this is the +/- permissible variance in degrees (>= 1) -# -# Other workflow options -# -startup_home_if_unloaded: 0 # 1 = force mmu homing on startup if unloaded, 0 = do nothing -startup_reset_ttg_map: 0 # 1 = reset TTG map on startup, 0 = do nothing -show_error_dialog: 1 # 1 = show pop-up dialog in addition to console message, 0 = show error in console -preload_attempts: 5 # How many "grabbing" attempts are made to pick up the filament with preload feature -strict_filament_recovery: 0 # If enabled with MMU with toolhead sensor, this will cause filament position recovery to - # perform extra moves to look for filament trapped in the space after extruder but before sensor -filament_recovery_on_pause: 1 # 1 = Run a quick check to determine current filament position on pause/error, 0 = disable -retry_tool_change_on_error: 0 # Whether to automatically retry a failed tool change. If enabled Happy Hare will perform - # the equivalent of 'MMU_RECOVER' + 'Tx' commands which usually is all that is necessary - # to recover. Note that enabling this can mask problems with your MMU -bypass_autoload: 1 # If extruder sensor fitted this controls the automatic loading of extruder for bypass operation -has_filament_buffer: 1 # Whether the MMU has a filament buffer. Set to 0 if using Filamentalist or DC eSpooler, etc -# -# Advanced options. Don't mess unless you fully understand. Read documentation. -# -encoder_move_validation: 1 # ADVANCED: 1 = Normally Encoder validates move distances are within given tolerance - # 0 = Validation is disabled (eliminates slight pause between moves but less safe) -print_start_detection: 1 # ADVANCED: Enabled for Happy Hare to automatically detect start and end of print and call - # ADVANCED: MMU_PRINT_START and MMU_PRINT_END automatically. Harmless to leave enabled but can disable - # if you think it is causing problems and known START/END is covered in your macros -extruder: extruder # ADVANCED: Name of the toolhead extruder that MMU is using -gcode_load_sequence: 0 # VERY ADVANCED: Gcode loading sequence 1=enabled, 0=internal logic (default) -gcode_unload_sequence: 0 # VERY ADVANCED: Gcode unloading sequence, 1=enabled, 0=internal logic (default) - - -# ADVANCED: Klipper tuning ------------------------------------------------------------------------------------------- -# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ -# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ -# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ -# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ -# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -# Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing -# operations especially so with CANbus connected MCUs. Happy Hare uses many homing moves for reliable extruder loading -# and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error -# -update_trsync: 0 # 1 = Increase TRSYNC_TIMEOUT, 0 = Leave the klipper default -# -# Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms timeout will kill -# the print. Since it seems to occur only on homing moves they can be safely retried to workaround. This has been -# working well in practice -canbus_comms_retries: 3 # Number of retries. Recommend the default of 3. -# -# Older neopixels have very finicky timing and can generate lots of "Unable to obtain 'neopixel_result' response" -# errors in klippy.log. An often cited workaround is to increase BIT_MAX_TIME in neopixel.py. This option does that -# automatically for you to save dirtying klipper -update_bit_max_time: 1 # 1 = Increase BIT_MAX_TIME, 0 = Leave the klipper default -# -# BTT ViViD used a AHT30 sensor. If you are using an older klipper you may not have this sensor available. If so, use -# AHT10 and set this to 1 to convert AHT30 commands to AHT10 -update_aht10_commands: 0 # 1 = Config AHT10 for BTT ViViD heater sensor on older klipper, 0 = Leave the klipper default - - -# ADVANCED: MMU macro overrides --- ONLY SET IF YOU'RE COMFORTABLE WITH KLIPPER MACROS ------------------------------- -# ███╗ ███╗ █████╗ ██████╗██████╗ ██████╗ ███████╗ -# ████╗ ████║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔════╝ -# ██╔████╔██║███████║██║ ██████╔╝██║ ██║███████╗ -# ██║╚██╔╝██║██╔══██║██║ ██╔══██╗██║ ██║╚════██║ -# ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║╚██████╔╝███████║ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ -# -# 'pause_macro' defines what macro to call on MMU error (must put printer in paused state) -# Other macros are detailed in 'mmu_sequence.cfg' -# Also see form_tip_macro in Tip Forming section and purge_macro in Purging section -# -pause_macro: PAUSE # What macro to call to pause the print -action_changed_macro: _MMU_ACTION_CHANGED # Called when action (printer.mmu.action) changes -print_state_changed_macro: _MMU_PRINT_STATE_CHANGED # Called when print state (printer.mmu.print_state) changes -mmu_event_macro: _MMU_EVENT # Called on useful MMU events -pre_unload_macro: _MMU_PRE_UNLOAD # Called before starting the unload -post_form_tip_macro: _MMU_POST_FORM_TIP # Called immediately after tip forming -post_unload_macro: _MMU_POST_UNLOAD # Called after unload completes -pre_load_macro: _MMU_PRE_LOAD # Called before starting the load -post_load_macro: _MMU_POST_LOAD # Called after the load is complete -unload_sequence_macro: _MMU_UNLOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_unload_sequence' -load_sequence_macro: _MMU_LOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_load_sequence' - - -# ADVANCED: See documentation for use of these ----------------------------------------------------------------------- -# ██████╗ ███████╗███████╗███████╗████████╗ ██████╗ ███████╗███████╗███████╗ -# ██╔══██╗██╔════╝██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██╔════╝ -# ██████╔╝█████╗ ███████╗█████╗ ██║ ██║ ██║█████╗ █████╗ ███████╗ -# ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║ ██║ ██║██╔══╝ ██╔══╝ ╚════██║ -# ██║ ██║███████╗███████║███████╗ ██║ ██████╔╝███████╗██║ ███████║ -# ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ -# -# These are the values that the various "RESET" commands will reset too rather than the built-in defaults. The lenght -# of the lists must match the number of gates on your MMU -# -# e.g. MMU_GATE_MAP RESET=1 - will use all the 'gate_XXX' values -# MMU_TTG_MAP RESET=1 - will use the 'tool_to_gate_map' -# MMU_ENDLESS_SPOOL_GROUPS RESET=1 - will use the 'endless_spool_groups' -# -# Gate: #0 #1 #2 #3 #4 #5 #6 #7 #8 -#gate_status: 1, 0, 1, 2, 2, -1, -1, 0, 1 -#gate_filament_name: one, two, three, four, five, six, seven, eight, nine -#gate_material: PLA, ABS, ABS, ABS+, PLA, PLA, PETG, TPU, ABS -#gate_color: red, black, yellow, green, blue, indigo, ffffff, grey, black -#gate_temperature: 210, 240, 235, 245, 210, 200, 215, 240, 240 -#gate_spool_id: 3, 2, 1, 4, 5, 6, 7, -1, 9 -#gate_speed_override: 100, 100, 100, 100, 100, 100, 100, 50, 100 -#endless_spool_groups: 0, 1, 2, 1, 0, 0, 3, 4, 1 -# -# Tool: T0 T1 T2 T3 T4 T5 T6 T7 T8 -#tool_to_gate_map: 0, 1, 2, 3, 4, 5, 6, 7, 8 - - -# ADVANCED/CUSTOM MMU: See documentation for use of these ------------------------------------------------------------ -# ██████╗██╗ ██╗███████╗████████╗ ██████╗ ███╗ ███╗ ███╗ ███╗███╗ ███╗██╗ ██╗ -# ██╔════╝██║ ██║██╔════╝╚══██╔══╝██╔═══██╗████╗ ████║ ████╗ ████║████╗ ████║██║ ██║ -# ██║ ██║ ██║███████╗ ██║ ██║ ██║██╔████╔██║ ██╔████╔██║██╔████╔██║██║ ██║ -# ██║ ██║ ██║╚════██║ ██║ ██║ ██║██║╚██╔╝██║ ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ -# ╚██████╗╚██████╔╝███████║ ██║ ╚██████╔╝██║ ╚═╝ ██║ ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ -# ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ -# -# Normally all these settings are set based on your choice of 'mmu_vendor' and 'mmu_version' in mmu_hardware.cfg, but they -# can be overridden. If you have selected a vendor of "Other" and your MMU has a selector you must set these CAD based -# dimensions else you will get arbitrary defaults. You may also need to set additional attributes in '[mmu_machine]' -# section of mmu_hardware.cfg. -# -#cad_gate0_pos: 4.2 # Approximate distance from endstop to first gate. Used for rough calibration only -#cad_gate_width: 21.0 # Width of each gate -#cad_bypass_offset: 0 # Distance from limit of travel back to the bypass (e.g. ERCF v2.0) -#cad_last_gate_offset: 2.0 # Distance from limit of travel back to last gate -#cad_selector_tolerance: 10.0 # How much extra selector movement to allow for calibration -#cad_gate_directions = [1, 1, 0, 0] # Directions of gear depending on gate (3DChameleon) -#cad_release_gates = [2, 3, 0, 1] # Gate to move to when releasing filament (3DChameleon) diff --git a/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.ss b/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.ss deleted file mode 100644 index c728f9d4fd0b..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.ss +++ /dev/null @@ -1,783 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# Template file for MMU's with Servo Selector (Type-A designs like PicoMMU and MMX) -# This file omits selector and selector-servo parts of the configuration and a few other options that don't make sense -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Main configuration parameters for the klipper module -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# Notes: -# Macro configuration is specified separately in 'mmu_macro_vars.cfg'. -# Full details in https://github.com/moggieuk/Happy-Hare/tree/main/doc/configuration.md -# -[mmu] -happy_hare_version: {happy_hare_version} # Don't mess, used for upgrade detection - -# MMU Hardware Limits -------------------------------------------------------------------------------------------------- -# ██╗ ██╗███╗ ███╗██╗████████╗███████╗ -# ██║ ██║████╗ ████║██║╚══██╔══╝██╔════╝ -# ██║ ██║██╔████╔██║██║ ██║ ███████╗ -# ██║ ██║██║╚██╔╝██║██║ ██║ ╚════██║ -# ███████╗██║██║ ╚═╝ ██║██║ ██║ ███████║ -# ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ -# -# Define the physical limits of your MMU. These settings will be respected regardless of individual speed settings. -# -gear_max_velocity: 300 # Never to be exceeded gear velocity regardless of specific parameters -gear_max_accel: 1500 # Never to be exceeded gear acceleration regardless of specific parameters - - -# Selector servo configuration ---------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ -# ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗ -# ███████╗█████╗ ██████╔╝██║ ██║██║ ██║ -# ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║ -# ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝ -# ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ -# -# Selector servo positions are stored in `mmu_vars.cfg` after calibration. -# Note that the "release angle" is by default the nearest position between calibrated selection angles. This can be overriden -# by setting and explicit servo_release_angle -# -# Note that leaving the servo active when down can stress the electronics and is not recommended unless you have a good -# 5v power supply. Make sure your hardware is suitable for the job! -# -servo_duration: 0.5 # Duration of PWM burst sent to servo (default non-active mode, automatically turns off) -servo_dwell: 0.8 # Minimum time given to servo to complete movement prior to next move -servo_always_active: 0 # CAUTION: 1=Force servo to always stay active, 0=Release after movement -selector_gate_angles: 45, 90, 135, 180 # Optionally set default list of gate angles (overriden by calibration) -selector_bypass_angle: -1 # Optionally set default servo angle when bypass is selected, -1=No default -selector_release_angle: -1 # Optionally force a specific "release" angle, -1=Default (between gate angles) behavior - - -# Logging -------------------------------------------------------------------------------------------------------------- -# ██╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ -# ██║ ██╔═══██╗██╔════╝ ██╔════╝ ██║████╗ ██║██╔════╝ -# ██║ ██║ ██║██║ ███╗██║ ███╗██║██╔██╗ ██║██║ ███╗ -# ██║ ██║ ██║██║ ██║██║ ██║██║██║╚██╗██║██║ ██║ -# ███████╗╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝ -# ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# log_level & logfile_level can be set to one of (0 = essential, 1 = info, 2 = debug, 3 = trace, 4 = stepper moves) -# Generally you can keep console logging to a minimal whilst still sending debug output to the mmu.log file -# Increasing the console log level is only really useful during initial setup to save having to constantly open the log file -# Note: that it is not recommended to keep logging at level greater that 2 (debug) if not debugging an issue because -# of the additional overhead -# -log_level: 1 -log_file_level: 2 # Can also be set to -1 to disable log file completely -log_statistics: 1 # 1 to log statistics on every toolchange (default), 0 to disable (but still recorded) -log_visual: 1 # 1 log visual representation of filament, 0 = disable -log_startup_status: 1 # Whether to log tool to gate status on startup, 1 = summary (default), 0 = disable -log_m117_messages: 1 # Whether send toolchange message via M117 to screen - - -# Movement speeds ------------------------------------------------------------------------------------------------------ -# ███████╗██████╗ ███████╗███████╗██████╗ ███████╗ -# ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝ -# ███████╗██████╔╝█████╗ █████╗ ██║ ██║███████╗ -# ╚════██║██╔═══╝ ██╔══╝ ██╔══╝ ██║ ██║╚════██║ -# ███████║██║ ███████╗███████╗██████╔╝███████║ -# ╚══════╝╚═╝ ╚══════╝╚══════╝╚═════╝ ╚══════╝ -# -# Long moves are faster than the small ones and used for the bulk of the bowden movement. You can set two fast load speeds -# depending on whether pulling from the spool or filament buffer (if fitted and not the first time load). This can be helpful -# in allowing faster loading from buffer and slower when pulling from the spool because of the additional friction (prevents -# loosing steps). Unloading speed can be tuning if you have a rewinder system that imposes additional limits. -# NOTE: Encoder cannot keep up much above 450mm/s so make sure 'bowden_apply_correction' is off at very high speeds! -# -gear_from_spool_speed: 80 # mm/s Speed when loading from the spool (for the first time if has_filament_buffer: 1) -gear_from_spool_accel: 100 # Acceleration when loading from spool -gear_from_buffer_speed: 150 # mm/s Speed when loading filament from buffer. Conservative is 100mm/s, Max around 400mm/s -gear_from_buffer_accel: 400 # Normal acceleration when loading filament -gear_unload_speed: 80 # mm/s Use (lower) speed when unloading filament (defaults to "from spool" speed) -gear_unload_accel: 100 # Acceleration when unloading filament (defaults to "from spool" accel) -# -gear_short_move_speed: 80 # mm/s Speed when making short moves (like incremental retracts with encoder) -gear_short_move_accel: 600 # Usually the same as gear_from_buffer_accel (for short movements) -gear_short_move_threshold: 70 # Move distance that controls application of 'short_move' speed/accel -gear_homing_speed: 50 # mm/s Speed of gear stepper only homing moves (e.g. homing to gate or extruder) - -# Speeds of extruder movement. The 'sync' speeds will be used when gear and extruder steppers are moving in sync -# -extruder_load_speed: 16 # mm/s speed of load move inside extruder from homing position to meltzone -extruder_unload_speed: 16 # mm/s speed of unload moves inside of extruder (very initial move from meltzone is 50% of this) -extruder_sync_load_speed: 18 # mm/s speed of synchronized extruder load moves -extruder_sync_unload_speed: 18 # mm/s speed of synchronized extruder unload moves -extruder_homing_speed: 18 # mm/s speed of extruder only homing moves (e.g. to toolhead sensor) - -# When Happy Hare calls out to a macro for user customization and for parking moves these settings are applied and the previous -# values automatically restored afterwards. This allows for deterministic movement speed regardless of the starting state. -# -macro_toolhead_max_accel: 0 # Default printer toolhead acceleration applied when macros are run. 0 = use printer max -macro_toolhead_min_cruise_ratio: 0.5 # Default printer cruise ratio applied when macros are run - - -# Gate loading/unloading ----------------------------------------------------------------------------------------------- -# ██████╗ █████╗ ████████╗███████╗ ██╗ ██████╗ █████╗ ██████╗ -# ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██║ ███╗███████║ ██║ █████╗ ██║ ██║ ██║███████║██║ ██║ -# ██║ ██║██╔══██║ ██║ ██╔══╝ ██║ ██║ ██║██╔══██║██║ ██║ -# ╚██████╔╝██║ ██║ ██║ ███████╗ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -# These settings control the loading and unloading filament at the gate which is the parking position inside the MMU. -# Typically this would be switch sensor but you can also use an encoder. Even with encoder the endstop can be a switch -# and the encoder used for move verifcation (see advanced 'gate_endstop_to_encoder' option). Note that the `encoder` -# method, due to the nature of its operation will overshoot a little. This is not a problem in practice because the -# overshoot will simply be compensated for in the subsequent move. A +ve parking distance moves towards the MMU, -ve -# moves back through the endstop towards the toolhead. If the MMU has multiple bowden tubes then it is possible to home -# at the extruder sensor and avoid long bowden moves! -# -# Possible gate_homing_endstop names: -# encoder - Detect filament position using movement of the encoder -# mmu_gate - Use gate endstop -# mmu_gear - Use individual per-gate endstop (type-B MMU's) -# extruder - Use extruder entry sensor (Only for some type-B designs, see [mmu_machine] require_bowden_move setting) -# -gate_homing_endstop: encoder # Name of gate endstop, "encoder" forces use of encoder for parking -gate_homing_max: 70 # Maximum move distance to home to the gate (or actual move distance for encoder parking) -gate_preload_homing_max: 70 # Maximum homing distance to the mmu_gear endstop (if MMU is fitted with one) -gate_preload_parking_distance: 0 # Parking position relative to mmu_gear endstop (-ve value means move forward) -gate_unload_buffer: 50 # Amount to reduce the fast unload so that filament doesn't overshoot when parking -gate_parking_distance: 23 # Parking position in the gate (distance back from homing point, -ve value means move forward) -gate_endstop_to_encoder: 10 # Distance between gate endstop and encoder (IF both fitted. +ve if encoder after endstop) -gate_autoload: 1 # If pre-gate sensor fitted this controls the automatic loading of the gate -gate_final_eject_distance: 0 # Distance to eject filament on MMU_EJECT (Ignored by MMU_UNLOAD) - - -# Bowden tube loading/unloading ---------------------------------------------------------------------------------------- -# ██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ -# ██╔══██╗██╔═══██╗██║ ██║██╔══██╗██╔════╝████╗ ██║ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██████╔╝██║ ██║██║ █╗ ██║██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║███████║██║ ██║ -# ██╔══██╗██║ ██║██║███╗██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██║██║ ██║ -# ██████╔╝╚██████╔╝╚███╔███╔╝██████╔╝███████╗██║ ╚████║ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -bowden_homing_max: 2000 # Maximum attempted bowden move (for calibration). Should be larger than your actual bowden! - -# If you MMU is equiped with an encoder the following options are available: -# -# In addition to different bowden loading speeds for buffer and non-buffered filament it is possible to detect missed -# steps caused by "jerking" on a heavy spool. If bowden correction is enabled Happy Hare will "believe" the encoder -# reading and make correction moves to bring the filament to within the 'bowden_allowable_load_delta' of the end of -# bowden position (this does require a reliable encoder and is not recommended for very high speed loading >350mm/s) -# -bowden_apply_correction: 0 # 1 to enable, 0 disabled -bowden_allowable_load_delta: 20.0 # How close in mm the correction moves will attempt to get to target -# -# This saftey check uses the encoder to verify the filament is free of extruder before the fast bowden movement to -# reduce possibility of grinding filament. If enabled the trigger can be tuned by setting the "error tolerance" which -# represents the fraction of allowable mismatch between actual movement and that seen by encoder. Setting to 50% tolerance -# usually works well. Increasing will make test more tolerant. Value of 100% essentially disables error detection -# -bowden_pre_unload_test: 1 # 1 to check for bowden movement before full pull (slower), 0 don't check (faster) -bowden_pre_unload_error_tolerance: 50 # ADVANCED: tune pre_unload_test - - -# Extruder homing ----------------------------------------------------------------------------------------------------- -# ███████╗██╗ ██╗████████╗ ██╗ ██╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ -# ██╔════╝╚██╗██╔╝╚══██╔══╝ ██║ ██║██╔═══██╗████╗ ████║██║████╗ ██║██╔════╝ -# █████╗ ╚███╔╝ ██║ ███████║██║ ██║██╔████╔██║██║██╔██╗ ██║██║ ███╗ -# ██╔══╝ ██╔██╗ ██║ ██╔══██║██║ ██║██║╚██╔╝██║██║██║╚██╗██║██║ ██║ -# ███████╗██╔╝ ██╗ ██║██╗ ██║ ██║╚██████╔╝██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ -# ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# Happy Hare needs a reference "homing point" close to the extruder from which to accurately complete the loading of -# the toolhead. This homing operation takes place after the fast bowden load and it is anticipated that that load -# operation will leave the filament just shy of the homing point. If using a toolhead sensor this initial extruder -# homing is unnecessary (but can be forced) because the homing will occur inside the extruder for the optimum in accuracy. -# You still should set this homing method because it is also used for the determination and calibration of bowden length. -# -# In addition to an entry sensor "extruder" it is possible for Happy Hare to "feel" for the extruder gear entry -# by colliding with it. This can be done with encoder based collision detection, the compression of the sync-feedback -# (aka buffer) sensor or using "touch" (stallguard) on the gear stepper. Note that encoder collision detection is not -# completely deterministic and you will have to find the sweetspot for your setup by adjusting the TMC current reduction. -# Note that reduced current during collision detection can also prevent unecessary filament griding. -# -# Possible extruder_homing_endtop names: -# collision - Detect the collision with the extruder gear by monitoring encoder movement (Requires encoder) -# Fast bowden load will move to the extruder gears -# mmu_gear_touch - Use touch detection when the gear stepper hits the extruder (Requires stallguard) -# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home -# extruder - If you have a "filament entry" endstop configured (Requires 'extruder' endstop) -# Fast bowden load will move to extruder_homing_buffer distance before sensor, then home -# filament_compression - If you have a "sync-feedback" sensor with compression switch configured -# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home -# none - Don't attempt to home. Only possibiliy if lacking all sensor options -# Fast bowden load will move to the extruder gears. Option is fine if using toolhead sensor -# Note: The homing_endstop will be ignored ("none") if a toolhead sensor is available unless "extruder_force_homing: 1" -# -extruder_homing_max: 80 # Maximum distance to advance in order to attempt to home the extruder -extruder_homing_endstop: collision # Filament homing method/endstop name (fallback if toolhead sensor not available) -extruder_homing_buffer: 25 # Amount to reduce the fast bowden load so filament doesn't overshoot the extruder homing point -extruder_collision_homing_current: 30 # % gear_stepper current (10%-100%) to use when homing to extruder homing (100 to disable) - -# If you have a toolhead sensor it will always be used as a homing point making the homing outside of the extruder -# potentially unnecessary. However you can still force this initial homing step by setting this option in which case -# the filament will home to the extruder and then home to the toolhead sensor in two steps -# -extruder_force_homing: 0 - - -# Toolhead loading and unloading -------------------------------------------------------------------------------------- -# ████████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗███████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ -# ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██║ ██║ ██║██║ ██║██║ ███████║█████╗ ███████║██║ ██║ ██║ ██║ ██║███████║██║ ██║ -# ██║ ██║ ██║██║ ██║██║ ██╔══██║██╔══╝ ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║ ██║ -# ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║███████╗██║ ██║██████╔╝ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -# It is possible to define highly customized loading and unloading sequences, however, unless you have a specialized -# setup it is probably easier to opt for the built-in toolhead loading and unloading sequence which already offers a -# high degree of customization. If you need even more control then edit the _MMU_LOAD_SEQUENCE and _MMU_UNLOAD_SEQUENCE -# macros in mmu_sequence.cfg - but be careful! -# -# An MMU must have a known point at the end of the bowden from which it can precisely load the extruder. Generally this -# will either be the extruder entrance (which is controlled with settings above) or by homing to toolhead sensor. If -# you have toolhead sensor it is past the extruder gear and the driver needs to know the max distance (from end of -# bowden move) to attempt homing -# -toolhead_homing_max: 40 # Maximum distance to advance in order to attempt to home to defined homing endstop - -# IMPORTANT: These next three settings are based on the physical dimensions of your toolhead -# Once a homing position is determined, Happy Hare needs to know the final move distance to the nozzle. There is only -# one correct value for your setup - use 'toolhead_ooze_reduction' (which corresponds to the residual filament left in -# your nozzle) to control excessive oozing on load. See doc for table of proposed values for common configurations. -# -# NOTE: If you have a toolhead sensor you can automate the calculation of these parameters! Read about the -# `MMU_CALIBRATE_TOOLHEAD` command (https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing#---calibrating-toolhead) -# -toolhead_extruder_to_nozzle: 72 # Distance from extruder gears (entrance) to nozzle -toolhead_sensor_to_nozzle: 62 # Distance from toolhead sensor to nozzle (ignored if not fitted) -toolhead_entry_to_extruder: 8 # Distance from extruder "entry" sensor to extruder gears (ignored if not fitted) - -# This setting represents how much residual filament is left behind in the nozzle when filament is removed, it is thus -# used to reduce the extruder loading length and prevent excessive blobbing but also in the calculation of purge volume. -# Note that this value can also be measured with the `MMU_CALIBRATE_TOOLHEAD` procedure -# -toolhead_residual_filament: 0 # Reduction in extruder loading length because of residual filament left behind - -# TUNING: Finally, this is the last resort tuning value to fix blobbing. It is expected that this value is NEAR ZERO as -# it represents a further reduction in extruder load length to fix blobbing. If using a wipetower and you experience blobs -# on it, increase this value (reduce the quantity of filament loaded). If you experience gaps, decrease this value. If gaps -# and already at 0 then perhaps the 'toolhead_extruder_to_nozzle' or 'toolhead_residual_filament' settings are incorrect. -# Similarly a value >+5mm also suggests the four settings above are not correct. Also see 'retract' setting in -# 'mmu_macro_vars.cfg' for final in-print ooze tuning. -# -toolhead_ooze_reduction: 0 # Reduction in extruder loading length to prevent ooze (represents filament remaining) - -# Distance added to the extruder unload movement to ensure filament is free of extruder. This adds some degree of tolerance -# to slightly incorrect configuration or extruder slippage. However don't use as an excuse for incorrect toolhead settings -# -toolhead_unload_safety_margin: 10 # Extra movement safety margin (default: 10mm) - -# If not synchronizing gear and extruder and you experience a "false" clog detection immediately after the tool change -# it might be because of a long bowden and/or large internal diameter that causes slack in the filament. This optional -# move will tighten the filament after a load by % of current clog detection length. Gear stepper will run at 50% current -# -toolhead_post_load_tighten: 60 # % of clog detection length, 0 to disable. Ignored if 'sync_to_extruder: 1' - -# If synchronizing gear and extruder and you have a sync-feedback "buffer" this setting determines whether to use it -# to create neutral tension after loading -toolhead_post_load_tension_adjust: 1 # 1 to enable (recommended), 0 to disable - -# If sync-feedback compression sensor is available this test will ensure the filament passes the extruder entry by checking -# for neutral tension when moving filament with just the extruder. Recommended with sprung loaded sync-feedback buffers. -# This is ignored if toolhead sensor is available. -toolhead_entry_tension_test: 1 # 1 to enable (recommended), 0 to disable - -# ADVANCED: Controls the detection of successful extruder load/unload movement and represents the fraction of allowable -# mismatch between actual movement and that seen by encoder. Setting to 100% tolerance effectively turns off checking. -# Some designs of extruder have a short move distance that may not be picked up by encoder and cause false errors. This -# allows masking of those errors. However the error often indicates that your extruder load speed is too high or the -# friction is too high on the filament and in that case masking the error is not a good idea. Try reducing friction -# and lowering speed first! -# -toolhead_move_error_tolerance: 60 - - -# Tip forming --------------------------------------------------------------------------------------------------------- -# ████████╗██╗██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ -# ╚══██╔══╝██║██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ -# ██║ ██║██████╔╝ █████╗ ██║ ██║██████╔╝██╔████╔██║██║██╔██╗ ██║██║ ███╗ -# ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██║ ██║ -# ██║ ██║██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# Tip forming responsibility can be split between slicer (in-print) and standalone macro (not in-print) or forced to always -# be done by Happy Hare's standalone macro. Since you always need the option to form tips without the slicer so it is -# generally easier to completely turn off the slicer, force "standalone" tip forming and tune only in Happy Hare. -# -# When Happy Hare is asked to form a tip it will run the referenced macro. Two are reference examples are provided but -# you can implement your own: -# _MMU_FORM_TIP .. default tip forming similar to popular slicers like Superslicer and Prusaslicer -# _MMU_CUT_TIP .. for Filametrix (originally ERCFv2) or similar style toolhead filament cutting system -# -# NOTE: For MMU located cutting like the optional EREC cutter you should set still this to _MMU_FORM_TIP to build a decent -# tip prior to extraction and cutting after the unload. -# -# Often it is useful to increase the extruder current for the rapid movement to ensure high torque and no skipped steps -# -# If opting for slicer tip forming you MUST configure where the slicer leaves the filament in the extruder since -# there is no way to determine this. This can be ignored if all tip forming is performed by Happy Hare -# -force_form_tip_standalone: 1 # 0 = Slicer in print else standalone, 1 = Always standalone tip forming (TURN SLICER OFF!) -form_tip_macro: _MMU_FORM_TIP # Name of macro to call to perform the tip forming (or cutting) operation -extruder_form_tip_current: 100 # % of extruder current (100%-150%) to use when forming tip (100 to disable) -slicer_tip_park_pos: 0 # This specifies the position of filament in extruder after slicer completes tip forming - - -# Purging ------------------------------------------------------------------------------------------------------------- -# ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ -# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██║████╗ ██║██╔════╝ -# ██████╔╝██║ ██║██████╔╝██║ ███╗██║██╔██╗ ██║██║ ███╗ -# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║ -# ██║ ╚██████╔╝██║ ██║╚██████╔╝██║██║ ╚████║╚██████╔╝ -# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# After a toolchange it is necessary to purge the old filament. Similar to tip forming this can be done by the slicer and/or -# by Happy Hare using an extension like Blobifer. If a purge_macro is defined it will be called when not printing or whenever -# the slicer isn't going to purge (like initial tool load). You can force it to always be called in a print by setting -# force_purge_standalone, but remember to turn off the slicer wipetower -# -# The default is for no (empty) macro so purging will not be done out of a print and thus wipetower. Two options are shipped with -# Happy Hare but you can also build your own custom one: -# _MMU_PURGE .. default purging that just dumps the desired amount of filament (setup correct parking before enabling this!) -# BLOBIFER .. for excellent Blobifer addon (https://github.com/Dendrowen/Blobifier) -# -# Often it is useful to increase the extruder current for the often rapid puring movement to ensure high torque and no skipped steps -# -force_purge_standalone: 0 # 0 = Slicer wipetower in print else standalone, 1 = Always standalone purging (TURN WIPETOWER OFF!) -purge_macro: _MMU_PURGE # Name of macro to call to perform the standalone purging operation. E.g. BLOBIFIER, _MMU_PURGE -extruder_purge_current: 100 # % of extruder current (100%-150%) to use when purging (100 to disable) - - -# Synchronized gear/extruder movement ---------------------------------------------------------------------------------- -# ███╗ ███╗ ██████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗███╗ ██╗ ██████╗ -# ████╗ ████║██╔═══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██╔════╝╚██╗ ██╔╝████╗ ██║██╔════╝ -# ██╔████╔██║██║ ██║ ██║ ██║ ██║██████╔╝ ███████╗ ╚████╔╝ ██╔██╗ ██║██║ -# ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║██╔══██╗ ╚════██║ ╚██╔╝ ██║╚██╗██║██║ -# ██║ ╚═╝ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║ ███████║ ██║ ██║ ╚████║╚██████╗ -# ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ -# -# This controls whether the extruder and gear steppers are synchronized during printing operations -# If you normally run with maxed out gear stepper current consider reducing it with 'sync_gear_current' -# If equipped with TMC drivers the current of the gear and extruder motors can be controlled to optimize performance. -# This can be useful to control gear stepper temperature when printing with synchronized motor -# -sync_to_extruder: 0 # Gear motor is synchronized to extruder during print -sync_gear_current: 70 # % of gear_stepper current (10%-100%) to use when syncing with extruder during print -sync_form_tip: 0 # Synchronize during standalone tip formation (initial part of unload) -sync_purge: 0 # Synchronize during standalone purging (last part of load) - -# Optionally it is possible to leverage feedback from a "compression/expansion" sensor (aka "buffer") in the bowden -# path from MMU to extruder to ensure that the two motors are kept in sync as viewed by the filament (the signal feedback -# state can be binary supplied by one or two switches: -1 (expanded) and 1 (compressed) of proportional value between -# -1.0 and 1.0. -# -# If only "one half" of the sync-feedback is available (either compression-only or tension-only) then the rotation -# distance is always shifted based on the high/low multipliers, however if both tension and compression are available -# then the rotation distance will autotune to correct setting (recommend you also enable 'autotune_rotation_distance: 1' -# Note that proportional feedback sensors are continuously dynamic -# -# Possible buffer setups, forth option for type where neutral is when both sensors are active: -# -# <------maxrange------> <------maxrange------> <------maxrange------> <------maxrange------> -# <--range---> <----range-----> <----range-----> <> range=0 -# |====================| |====================| |====================| |====================| -# ^ ^ ^ ^ ^^ -# compression tension compression-only tension-only -# -sync_feedback_enabled: 0 # Turn off even if sensor is installed and active -sync_feedback_buffer_range: 6 # Travel in "buffer" between compression/tension or one sensor and end (see above) -sync_feedback_buffer_maxrange: 12 # Absolute maximum end-to-end travel (mm) provided by buffer (see above) -sync_feedback_speed_multiplier: 5 # % "twolevel" gear speed delta to keep filament neutral in buffer (recommend 5%) -sync_feedback_boost_multiplier: 3 # % "twolevel" extra gear speed boost for finding initial neutral position (recommend 3%) -sync_feedback_extrude_threshold: 5 # Extruder movement (mm) for updates (keep small but set > retract distance) - -# If defined this forces debugging to a telemetry log file "sync_.jsonl". This is great if trying to tune clog/tangle -# detection or for getting help on the Happy Hare forum. To plot graph of sync-feedback operation, run: -# ~/Happy-Hare/utils/plot_sync_feedback.sh -# -sync_feedback_debug_log: 0 # 0 = disable (normal opertion), 1 = enable telemetry log (for debugging) - - -# ESpooler control ----------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ -# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ -# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ -# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ -# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ -# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ -# -# If your MMU has a dc motor (often N20) controlled respooler/assist then how it operates can be controlled with these -# settings. Typically the espooler will be controlled with PWM signal. This will be at the maximum at speeds equal or -# above 'espooler.max_stepper_speed'. The PWM signal will scale downwards towards 0 for slower speeds. The falloff being -# controlled by the 'espooler_speed_exponent' setting according to this formula and allows for non-linear characteristics -# the DC motor (0.5 is a good starting value). -# -# espooler_pwm = (stepper_speed / espooler_max_stepper_speed) ^ {espooler_speed_exponent} -# -# Regardless of h/w configuration you can enable/disable actions with the 'espooler_operations' list. E.g. remove 'play' to -# turn off operation while printing. Options are: -# -# rewind - when filament is being unloaded under MMU control (aka respool) -# assist - when filament is being loaded under MMU control (% of "rewind" speed but with minimum of "print" power) -# print - while printing. Generally set 'espooler_printing_power' to a low percentage just to allow motor to be turned -# freely or set to 0 to enable/allow "burst" assist movements -# -# If using a digitally controlled espooler motor (not PWM) then you should turn off the "print" mode and set -# 'espooler_min_stepper_speed' to prevent "over movement" -# -espooler_min_distance: 30 # Individual stepper movements less than this distance will not active espooler -espooler_max_stepper_speed: 300 # Gear stepper speed at which espooler will be at maximum power -espooler_min_stepper_speed: 0 # Gear stepper speed at which espooler will become inactive (useful for non PWM control) -espooler_speed_exponent: 0.5 # Controls non-linear espooler power relative to stepper speed (see notes) -espooler_assist_reduced_speed: 50 # Control the % of the rewind speed that is applied to assisting load (want rewind to be faster) -espooler_printing_power: 0 # If >0, fixes the % of PWM power while printing. 0=allows burst movement -espooler_operations: rewind, assist, print # List of operational modes (allows disabling even if h/w is configured) -# -# The following burst configuration is used to control the small rotation in the ASSIST direction optionally used -# when in 'print' operation is enabled, 'espooler_printing_power: 0' and is triggered (tension switch or extruder movement). -# It can also be used to loosen filament with 'MMU_ESPOOLER COMMAND=assist BURST=1' -# -espooler_assist_extruder_move_length: 100 # Distance (mm) extruder needs to move between each assist burst -espooler_assist_burst_power: 100 # The % power of the burst move -espooler_assist_burst_duration: 0.4 # The duration of the burst move is seconds -espooler_assist_burst_trigger: 0 # If trigger assist switch is fitted 0=disable, 1=enable -espooler_assist_burst_trigger_max: 3 # If trigger assist switch is fitted this limits the max number of back-to-back advances -# -# The following burst configuration is used to control the small rotation in the REWIND direction optionally used -# when running running the filament drying cycle. The goal is to rotate the spool 60-90 degrees. It can also be -# used to tighten the filament with 'MMU_ESPOOLER COMMAND=rewind BURST=1' -# -espooler_rewind_burst_power: 100 # The % power of the rewind burst move -espooler_rewind_burst_duration: 0.4 # The duration of the rewind burst move is seconds - - -# Heater / Environment Management ------------------------------------------------------------------------------------ -# ██╗ ██╗███████╗ █████╗ ████████╗███████╗██████╗ -# ██║ ██║██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ -# ███████║█████╗ ███████║ ██║ █████╗ ██████╔╝ -# ██╔══██║██╔══╝ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ -# ██║ ██║███████╗██║ ██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -heater_max_temp: 70 # Absolute max heater setting to protect the MMU enclosure construction (adjust to match material) -heater_default_dry_temp: 45 # Default drying temperature if filament type is not matched in drying_data -heater_default_dry_time: 300 # Default drying cycle time in minutes -heater_default_humidity: 25 # Default humidity % goal. Drying will terminate if this value is reached -heater_vent_macro: _MMU_VENT # Name of macro to periodicaly call during drying cycle -heater_vent_interval: 0 # Interval in minutes to call heater_vent_macro during drying cycle, 0=disable venting -heater_rotate_interval: 5 # Interval in minutes to rotate filament (requires eSpooler and filament end attached to spool) - -# Drying data for MMU_HEATER DRY=1 command in form (material type is case insensitive): -# 'filament_type': (temp, drying_time_mins) -# -# (Careful with formatting of this line - reformatting will break upgrade logic) -# -drying_data: { 'pla': (45, 300), 'pla+': (55, 300), 'petg': (60, 300), 'tpu': (55, 300), 'abs': (70, 300), 'abs+': (75, 300), 'asa': (65, 300), 'nylon': (75, 600), 'pc': (75, 600), 'pva': (75, 600), 'hips': (75, 600) } - - -# FlowGuard Clog and Tangle Detection -------------------------------------------------------------------------------- -# ███████╗██╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ -# ██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗ -# █████╗ ██║ ██║ ██║██║ █╗ ██║██║ ███╗██║ ██║███████║██████╔╝██║ ██║ -# ██╔══╝ ██║ ██║ ██║██║███╗██║██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║ -# ██║ ███████╗╚██████╔╝╚███╔███╔╝╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝ -# ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ -# -# Options are available to automatically detects extruder clogs and MMU tangles. Each option works independently and -# can be combined. Flowguard can even discern the difference between an extruder clog and a spool tangle! -# -# Flowguard: This intelligently measures filament tension (only available if sync-feedback buffer is fitted) -# -# Encoder detection: This monitors encoder movement and compares to extruder (only available if encoder is fitted) -# -flowguard_enabled: 1 # 0 = Flowguard protection disabled, 1 = Enabled - -# The flowguard_max_relief is the amount of relief movement (effective mm change in filament length between MMU and extruder) -# that Happy Hare will wait until triggering a clog or runout. A smaller value is more sensitive to triggering. Since the -# relief movement is hightly dependent on filament "spring" in the bowden tube, filament friction, and -# 'sync_feedback_buffer_range', it is generally good to start high and then decrease if a more sensitive trigger is desired. -# Analog proportional (type P) sensors can generally have a much lower value. Increase if you have false triggers. -flowguard_max_relief: 40 - -# Encoder runout/clog/tangle detection watches for movement over either a static or automatically adjusted distance - if -# no encoder movement is seen when the extruder moves this distance runout/ clog/tangle event will be generated. Allowing -# the distance to be adjusted automatically (mode=2) will generally allow for a quicker trigger but use a static length -# (mode=1, set encoder_max_motion) if you get false triggers (see flowguard guide on wiki for more details). -# Note that this feature cannot disinguish between clog or tangle. -flowguard_encoder_mode: 2 # 0 = Disable, 1 = Static length clog detection, 2 = Automatic length clog detection - -# The encoder_max_motion is the absolute max permitted extruder movement without the encoder seeing movement when using -# status mode (mode=1). Smaller values are more sensitive but beware of going too small - slack and friction in the -# bowden may cause gaps in encoder movement. Increase if you have false triggers. -# Note that this value is overriden by any calibrated value stored in 'mmu_vars.cfg' if in automatic mode (mode=2). -flowguard_encoder_max_motion: 20 - - -# Filament Management Options ---------------------------------------------------------------------------------------- -# ███████╗██╗██╗ ███╗ ███╗ ██████╗ ███╗ ███╗████████╗ -# ██╔════╝██║██║ ████╗ ████║██╔════╝ ████╗ ████║╚══██╔══╝ -# █████╗ ██║██║ ██╔████╔██║██║ ███╗██╔████╔██║ ██║ -# ██╔══╝ ██║██║ ██║╚██╔╝██║██║ ██║██║╚██╔╝██║ ██║ -# ██║ ██║███████╗██╗ ██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ -# -# - EndlessSpool feature allows detection of runout on one spool and the automatic mapping of tool to an alternative -# gate (spool). Set to '1', this feature requires clog detection or gate sensor or pre-gate sensors. EndlessSpool -# functionality can optionally be extended to attempt to load an empty gate with 'endless_spool_on_load'. On some MMU -# designs (with linear selector) it can also be configured to eject filament remains to a designated gate rather than -# defaulting to current gate. A custom gate will disable pre-gate runout detection for EndlessSpool because filament -# end must completely pass through the gate for selector to move -# -endless_spool_enabled: 1 # 0 = disable, 1 = enable endless spool -endless_spool_on_load: 0 # 0 = don't apply endless spool on load, 1 = run endless spool if gate is empty -endless_spool_eject_gate: -1 # Which gate to eject the filament remains. -1 = current gate -#endless_spool_groups: # Default EndlessSpool groups (see later in file) -# -# Spoolman support requires you to correctly enable spoolman with moonraker first. If enabled, the gate SpoolId will -# be used to load filament details and color from the spoolman database and Happy Hare will activate/deactivate -# spools as they are used. The enabled variation allows for either the local map or the spoolman map to be the -# source of truth as well as just fetching filament attributes. See this table for explanation: -# -# | Activate/ | Fetch filament attributes | Filament gate | Filament gate | -# spoolman_support | Deactivate | attributes from spoolman | assignment shown | assignment pulled | -# | spool? | based on spool_id? | in spoolman db? | from spoolman db? | -# -----------------+------------+---------------------------+------------------+-------------------+ -# off | no | no | no | no | -# readonly | yes | yes | no | no | -# push | yes | yes | yes | no | -# pull | yes | yes | yes | yes | -# -spoolman_support: off # off = disabled, readonly = enabled, push = local gate map, pull = remote gate map -pending_spool_id_timeout: 20 # Seconds after which this pending spool_id (set with rfid) is voided -# -# Mainsail/Fluid UI can visualize the color of filaments next to the extruder/tool chooser. The color is dynamic and -# can be customized to your choice: -# -# slicer - Color from slicer tool map (what the slicer expects) -# allgates - Color from all the tools in the gate map after running through the TTG map -# gatemap - As per gatemap but hide empty tools -# off - Turns off support -# -# Note: Happy Hare will also add the 'spool_id' variable to the Tx macro if spoolman is enabled -# -t_macro_color: slicer # 'slicer' = default | 'allgates' = mmu | 'gatemap' = mmu without empty gates | 'off' - - -# Print Statistics --------------------------------------------------------------------------------------------------- -# ███████╗████████╗ █████╗ ████████╗███████╗ -# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ -# ███████╗ ██║ ███████║ ██║ ███████╗ -# ╚════██║ ██║ ██╔══██║ ██║ ╚════██║ -# ███████║ ██║ ██║ ██║ ██║ ███████║ -# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ -# -# These parameters determine how print statistic data is shown in the console. This table can show a lot of data, -# probably more than you'd want to see. Below you can enable/disable options to your needs. -# -# +-----------+---------------------+----------------------+----------+ -# | 114(46) | unloading | loading | complete | -# | swaps | pre | - | post | pre | - | post | swap | -# +-----------+------+-------+------+------+-------+-------+----------+ -# | all time | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | -# | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | -# | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | -# | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | -# | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | -# +-----------+------+-------+------+------+-------+-------+----------+ -# Note: Only formats correctly on Python3 -# -# Comma separated list of desired columns -# Options: pre_unload, form_tip, unload, post_unload, pre_load, load, purge, post_load, total -console_stat_columns: unload, load, post_load, total - -# Comma separated list of rows. The order determines the order in which they're shown. -# Options: total, total_average, job, job_average, last -console_stat_rows: total, total_average, job, job_average, last - -# How you'd want to see the state of the gates and how they're performing -# string - poor, good, perfect, etc.. -# percentage - rate of success -# emoticon - fun sad to happy faces (python3 only) -console_gate_stat: emoticon - -# Always display the full statistics table -console_always_output_full: 1 # 1 = Show full table, 0 = Only show totals out of print - - -# Calibration and autotune ------------------------------------------------------------------------------------------- -# ██████╗ █████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ -# ██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ -# ██║ ███████║██║ ██║██████╔╝██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ -# ██║ ██╔══██║██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ -# ╚██████╗██║ ██║███████╗██║██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ -# ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ -# -# These are auto calibration/tuning settings that can be used to ease initial setup and/or to tune calibration over -# time based on measured telemetry. Whether these auto-tuning features are available depends on MMU design and -# configured sensors (explained below). The setting will be ignored if the required sensors are not available but if -# they can operate they will suppress the normal calibration warnings (MMU_STATUS can still be used to view them). -# Note that these are initially set by the installer to recommended values -# -# autocal_bowden_length - the calibrated bowden length will be established on first load. It can also be set -# manually or reset with MMU_CALIBRATE_BOWDEN. Best results require the use of -# sync-feedback-compression or extruder sensor but gear-touch or encoder will also work. -# 'extruder_homing_endstop' cannot be 'none' -# autotune_bowden_length - Once calibrated this setting will tune the bowden distance over time. Works best with -# toolhead sensor -# skip_cal_rotation_distance - This will rely on installed default value (although it can still be calibrated). Usually -# a good choice if autotune is enabled -# autotune_rotation_distance - Requires sync-feedback sensor (aka "buffer") or calibrated encoder. If set then either the -# "autotuner" (sync-feedback buffer) or encoder telemetry will be used to adjust the -# persisted gear rotation distance. -# skip_cal_encoder - Will rely on installed default value (although it can still be calibrates). -# skip_cal_encoder - Will rely on installed default value (although it can still be calibrates). -# Not recommended but allows for easier initial setup especially when 'autotune_encoder' -# is enabled. -# autotune_encoder - NOT IMPLEMENTED YET. Soon! -# -autocal_bowden_length: 1 # Automated bowden length calibration. 1=automatic, 0=manual/off -autotune_bowden_length: 1 # Automated bowden length tuning. 1=on, 0=off -skip_cal_rotation_distance: 0 # Skip rotation distance calibration (MMU_CALIBRATE_GEAR), 1=skip, 0=require -autotune_rotation_distance: 0 # Automated gate calibration/tuning. 1=automatic, 0=manual/off -skip_cal_encoder: 0 # Skip encoder calibration (MMU_CALIBRATE_ENCODER), 1=skip, 0=require -autotune_encoder: 0 # Automated encoder tuning. 1=automatic, 0=manual/off - - -# Miscellaneous, but you should review ------------------------------------------------------------------------------- -# ███╗ ███╗██╗███████╗ ██████╗ -# ████╗ ████║██║██╔════╝██╔════╝ -# ██╔████╔██║██║███████╗██║ -# ██║╚██╔╝██║██║╚════██║██║ -# ██║ ╚═╝ ██║██║███████║╚██████╗ -# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ -# -# Important you verify these work for you setup/workflow. Temperature and timeouts -# -timeout_pause: 72000 # Idle time out (printer shuts down) in seconds used when in MMU pause state -disable_heater: 600 # Delay in seconds after which the hotend heater is disabled in the MMU_PAUSE state -default_extruder_temp: 200 # Default temperature for performing swaps and forming tips when not in print (overridden by gate map) -extruder_temp_variance: 2 # When waiting for extruder temperature this is the +/- permissible variance in degrees (>= 1) -# -# Other workflow options -# -startup_home_if_unloaded: 0 # 1 = force mmu homing on startup if unloaded, 0 = do nothing -startup_reset_ttg_map: 0 # 1 = reset TTG map on startup, 0 = do nothing -show_error_dialog: 1 # 1 = show pop-up dialog in addition to console message, 0 = show error in console -preload_attempts: 5 # How many "grabbing" attempts are made to pick up the filament with preload feature -strict_filament_recovery: 0 # If enabled with MMU with toolhead sensor, this will cause filament position recovery to - # perform extra moves to look for filament trapped in the space after extruder but before sensor -filament_recovery_on_pause: 1 # 1 = Run a quick check to determine current filament position on pause/error, 0 = disable -retry_tool_change_on_error: 0 # Whether to automatically retry a failed tool change. If enabled Happy Hare will perform - # the equivalent of 'MMU_RECOVER' + 'Tx' commands which usually is all that is necessary - # to recover. Note that enabling this can mask problems with your MMU -bypass_autoload: 1 # If extruder sensor fitted this controls the automatic loading of extruder for bypass operation -has_filament_buffer: 1 # Whether the MMU has a filament buffer. Set to 0 if using Filamentalist or DC eSpooler, etc -# -# Advanced options. Don't mess unless you fully understand. Read documentation. -# -encoder_move_validation: 1 # ADVANCED: 1 = Normally Encoder validates move distances are within given tolerance - # 0 = Validation is disabled (eliminates slight pause between moves but less safe) -print_start_detection: 1 # ADVANCED: Enabled for Happy Hare to automatically detect start and end of print and call - # ADVANCED: MMU_PRINT_START and MMU_PRINT_END automatically. Harmless to leave enabled but can disable - # if you think it is causing problems and known START/END is covered in your macros -extruder: extruder # ADVANCED: Name of the toolhead extruder that MMU is using -gcode_load_sequence: 0 # VERY ADVANCED: Gcode loading sequence 1=enabled, 0=internal logic (default) -gcode_unload_sequence: 0 # VERY ADVANCED: Gcode unloading sequence, 1=enabled, 0=internal logic (default) - - -# ADVANCED: Klipper tuning ------------------------------------------------------------------------------------------- -# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ -# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ -# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ -# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ -# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -# Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing -# operations especially so with CANbus connected MCUs. Happy Hare uses many homing moves for reliable extruder loading -# and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error -# -update_trsync: 0 # 1 = Increase TRSYNC_TIMEOUT, 0 = Leave the klipper default -# -# Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms timeout will kill -# the print. Since it seems to occur only on homing moves they can be safely retried to workaround. This has been -# working well in practice -canbus_comms_retries: 3 # Number of retries. Recommend the default of 3. -# -# Older neopixels have very finicky timing and can generate lots of "Unable to obtain 'neopixel_result' response" -# errors in klippy.log. An often cited workaround is to increase BIT_MAX_TIME in neopixel.py. This option does that -# automatically for you to save dirtying klipper -update_bit_max_time: 1 # 1 = Increase BIT_MAX_TIME, 0 = Leave the klipper default -# -# BTT ViViD used a AHT30 sensor. If you are using an older klipper you may not have this sensor available. If so, use -# AHT10 and set this to 1 to convert AHT30 commands to AHT10 -update_aht10_commands: 0 # 1 = Config AHT10 for BTT ViViD heater sensor on older klipper, 0 = Leave the klipper default - - -# ADVANCED: MMU macro overrides --- ONLY SET IF YOU'RE COMFORTABLE WITH KLIPPER MACROS ------------------------------- -# ███╗ ███╗ █████╗ ██████╗██████╗ ██████╗ ███████╗ -# ████╗ ████║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔════╝ -# ██╔████╔██║███████║██║ ██████╔╝██║ ██║███████╗ -# ██║╚██╔╝██║██╔══██║██║ ██╔══██╗██║ ██║╚════██║ -# ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║╚██████╔╝███████║ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ -# -# 'pause_macro' defines what macro to call on MMU error (must put printer in paused state) -# Other macros are detailed in 'mmu_sequence.cfg' -# Also see form_tip_macro in Tip Forming section and purge_macro in Purging section -# -pause_macro: PAUSE # What macro to call to pause the print -action_changed_macro: _MMU_ACTION_CHANGED # Called when action (printer.mmu.action) changes -print_state_changed_macro: _MMU_PRINT_STATE_CHANGED # Called when print state (printer.mmu.print_state) changes -mmu_event_macro: _MMU_EVENT # Called on useful MMU events -pre_unload_macro: _MMU_PRE_UNLOAD # Called before starting the unload -post_form_tip_macro: _MMU_POST_FORM_TIP # Called immediately after tip forming -post_unload_macro: _MMU_POST_UNLOAD # Called after unload completes -pre_load_macro: _MMU_PRE_LOAD # Called before starting the load -post_load_macro: _MMU_POST_LOAD # Called after the load is complete -unload_sequence_macro: _MMU_UNLOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_unload_sequence' -load_sequence_macro: _MMU_LOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_load_sequence' - - -# ADVANCED: See documentation for use of these ----------------------------------------------------------------------- -# ██████╗ ███████╗███████╗███████╗████████╗ ██████╗ ███████╗███████╗███████╗ -# ██╔══██╗██╔════╝██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██╔════╝ -# ██████╔╝█████╗ ███████╗█████╗ ██║ ██║ ██║█████╗ █████╗ ███████╗ -# ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║ ██║ ██║██╔══╝ ██╔══╝ ╚════██║ -# ██║ ██║███████╗███████║███████╗ ██║ ██████╔╝███████╗██║ ███████║ -# ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ -# -# These are the values that the various "RESET" commands will reset too rather than the built-in defaults. The lenght -# of the lists must match the number of gates on your MMU -# -# e.g. MMU_GATE_MAP RESET=1 - will use all the 'gate_XXX' values -# MMU_TTG_MAP RESET=1 - will use the 'tool_to_gate_map' -# MMU_ENDLESS_SPOOL_GROUPS RESET=1 - will use the 'endless_spool_groups' -# -# Gate: #0 #1 #2 #3 #4 #5 #6 #7 #8 -#gate_status: 1, 0, 1, 2, 2, -1, -1, 0, 1 -#gate_filament_name: one, two, three, four, five, six, seven, eight, nine -#gate_material: PLA, ABS, ABS, ABS+, PLA, PLA, PETG, TPU, ABS -#gate_color: red, black, yellow, green, blue, indigo, ffffff, grey, black -#gate_temperature: 210, 240, 235, 245, 210, 200, 215, 240, 240 -#gate_spool_id: 3, 2, 1, 4, 5, 6, 7, -1, 9 -#gate_speed_override: 100, 100, 100, 100, 100, 100, 100, 50, 100 -#endless_spool_groups: 0, 1, 2, 1, 0, 0, 3, 4, 1 -# -# Tool: T0 T1 T2 T3 T4 T5 T6 T7 T8 -#tool_to_gate_map: 0, 1, 2, 3, 4, 5, 6, 7, 8 diff --git a/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.vs b/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.vs deleted file mode 100644 index a295642daf49..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_parameters.cfg.vs +++ /dev/null @@ -1,756 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# -# Template file for MMU's with Virtual Selector (Type-B designs like Box Turtle, Night Owl, Angry Beaver, ...) -# This file omits selector and selector-servo configuration and a few other options that don't make sense -# -# EDIT THIS FILE BASED ON YOUR SETUP -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Main configuration parameters for the klipper module -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# Notes: -# Macro configuration is specified separately in 'mmu_macro_vars.cfg'. -# Full details in https://github.com/moggieuk/Happy-Hare/tree/main/doc/configuration.md -# -[mmu] -happy_hare_version: {happy_hare_version} # Don't mess, used for upgrade detection - -# MMU Hardware Limits -------------------------------------------------------------------------------------------------- -# ██╗ ██╗███╗ ███╗██╗████████╗███████╗ -# ██║ ██║████╗ ████║██║╚══██╔══╝██╔════╝ -# ██║ ██║██╔████╔██║██║ ██║ ███████╗ -# ██║ ██║██║╚██╔╝██║██║ ██║ ╚════██║ -# ███████╗██║██║ ╚═╝ ██║██║ ██║ ███████║ -# ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ -# -# Define the physical limits of your MMU. These settings will be respected regardless of individual speed settings. -# -gear_max_velocity: 300 # Never to be exceeded gear velocity regardless of specific parameters -gear_max_accel: 1500 # Never to be exceeded gear acceleration regardless of specific parameters - - -# Logging -------------------------------------------------------------------------------------------------------------- -# ██╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ -# ██║ ██╔═══██╗██╔════╝ ██╔════╝ ██║████╗ ██║██╔════╝ -# ██║ ██║ ██║██║ ███╗██║ ███╗██║██╔██╗ ██║██║ ███╗ -# ██║ ██║ ██║██║ ██║██║ ██║██║██║╚██╗██║██║ ██║ -# ███████╗╚██████╔╝╚██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝ -# ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# log_level & logfile_level can be set to one of (0 = essential, 1 = info, 2 = debug, 3 = trace, 4 = stepper moves) -# Generally you can keep console logging to a minimal whilst still sending debug output to the mmu.log file -# Increasing the console log level is only really useful during initial setup to save having to constantly open the log file -# Note: that it is not recommended to keep logging at level greater that 2 (debug) if not debugging an issue because -# of the additional overhead -# -log_level: 1 -log_file_level: 2 # Can also be set to -1 to disable log file completely -log_statistics: 1 # 1 to log statistics on every toolchange (default), 0 to disable (but still recorded) -log_visual: 1 # 1 log visual representation of filament, 0 = disable -log_startup_status: 1 # Whether to log tool to gate status on startup, 1 = summary (default), 0 = disable -log_m117_messages: 1 # Whether send toolchange message via M117 to screen - - -# Movement speeds ------------------------------------------------------------------------------------------------------ -# ███████╗██████╗ ███████╗███████╗██████╗ ███████╗ -# ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝ -# ███████╗██████╔╝█████╗ █████╗ ██║ ██║███████╗ -# ╚════██║██╔═══╝ ██╔══╝ ██╔══╝ ██║ ██║╚════██║ -# ███████║██║ ███████╗███████╗██████╔╝███████║ -# ╚══════╝╚═╝ ╚══════╝╚══════╝╚═════╝ ╚══════╝ -# -# Long moves are faster than the small ones and used for the bulk of the bowden movement. You can set two fast load speeds -# depending on whether pulling from the spool or filament buffer (if fitted and not the first time load). This can be helpful -# in allowing faster loading from buffer and slower when pulling from the spool because of the additional friction (prevents -# loosing steps). Unloading speed can be tuning if you have a rewinder system that imposes additional limits. -# NOTE: Encoder cannot keep up much above 450mm/s so make sure 'bowden_apply_correction' is off at very high speeds! -# -gear_from_spool_speed: 80 # mm/s Speed when loading from the spool (for the first time if has_filament_buffer: 1) -gear_from_spool_accel: 100 # Acceleration when loading from spool -gear_from_buffer_speed: 150 # mm/s Speed when loading filament from buffer. Conservative is 100mm/s, Max around 400mm/s -gear_from_buffer_accel: 400 # Normal acceleration when loading filament -gear_unload_speed: 80 # mm/s Use (lower) speed when unloading filament (defaults to "from spool" speed) -gear_unload_accel: 100 # Acceleration when unloading filament (defaults to "from spool" accel) -# -gear_short_move_speed: 80 # mm/s Speed when making short moves (like incremental retracts with encoder) -gear_short_move_accel: 600 # Usually the same as gear_from_buffer_accel (for short movements) -gear_short_move_threshold: 70 # Move distance that controls application of 'short_move' speed/accel -gear_homing_speed: 50 # mm/s Speed of gear stepper only homing moves (e.g. homing to gate or extruder) - -# Speeds of extruder movement. The 'sync' speeds will be used when gear and extruder steppers are moving in sync -# -extruder_load_speed: 16 # mm/s speed of load move inside extruder from homing position to meltzone -extruder_unload_speed: 16 # mm/s speed of unload moves inside of extruder (very initial move from meltzone is 50% of this) -extruder_sync_load_speed: 18 # mm/s speed of synchronized extruder load moves -extruder_sync_unload_speed: 18 # mm/s speed of synchronized extruder unload moves -extruder_homing_speed: 18 # mm/s speed of extruder only homing moves (e.g. to toolhead sensor) - -# When Happy Hare calls out to a macro for user customization and for parking moves these settings are applied and the previous -# values automatically restored afterwards. This allows for deterministic movement speed regardless of the starting state. -# -macro_toolhead_max_accel: 0 # Default printer toolhead acceleration applied when macros are run. 0 = use printer max -macro_toolhead_min_cruise_ratio: 0.5 # Default printer cruise ratio applied when macros are run - - -# Gate loading/unloading ----------------------------------------------------------------------------------------------- -# ██████╗ █████╗ ████████╗███████╗ ██╗ ██████╗ █████╗ ██████╗ -# ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██║ ███╗███████║ ██║ █████╗ ██║ ██║ ██║███████║██║ ██║ -# ██║ ██║██╔══██║ ██║ ██╔══╝ ██║ ██║ ██║██╔══██║██║ ██║ -# ╚██████╔╝██║ ██║ ██║ ███████╗ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -# These settings control the loading and unloading filament at the gate which is the parking position inside the MMU. -# Typically this would be switch sensor but you can also use an encoder. Even with encoder the endstop can be a switch -# and the encoder used for move verifcation (see advanced 'gate_endstop_to_encoder' option). Note that the `encoder` -# method, due to the nature of its operation will overshoot a little. This is not a problem in practice because the -# overshoot will simply be compensated for in the subsequent move. A +ve parking distance moves towards the MMU, -ve -# moves back through the endstop towards the toolhead. If the MMU has multiple bowden tubes then it is possible to home -# at the extruder sensor and avoid long bowden moves! -# -# Possible gate_homing_endstop names: -# encoder - Detect filament position using movement of the encoder -# mmu_gate - Use gate endstop -# mmu_gear - Use individual per-gate endstop (type-B MMU's) -# extruder - Use extruder entry sensor (Only for some type-B designs, see [mmu_machine] require_bowden_move setting) -# -gate_homing_endstop: encoder # Name of gate endstop, "encoder" forces use of encoder for parking -gate_homing_max: 70 # Maximum move distance to home to the gate (or actual move distance for encoder parking) -gate_preload_homing_max: 70 # Maximum homing distance to the mmu_gear endstop (if MMU is fitted with one) -gate_preload_parking_distance: -10 # Parking position relative to mmu_gear endstop (-ve value means move forward) -gate_unload_buffer: 50 # Amount to reduce the fast unload so that filament doesn't overshoot when parking -gate_parking_distance: 23 # Parking position in the gate (distance back from homing point, -ve value means move forward) -gate_endstop_to_encoder: 10 # Distance between gate endstop and encoder (IF both fitted. +ve if encoder after endstop) -gate_autoload: 1 # If pre-gate sensor fitted this controls the automatic loading of the gate -gate_final_eject_distance: 0 # Distance to eject filament on MMU_EJECT (Ignored by MMU_UNLOAD) - - -# Bowden tube loading/unloading ---------------------------------------------------------------------------------------- -# ██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███╗ ██╗ ██╗ ██████╗ █████╗ ██████╗ -# ██╔══██╗██╔═══██╗██║ ██║██╔══██╗██╔════╝████╗ ██║ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██████╔╝██║ ██║██║ █╗ ██║██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║███████║██║ ██║ -# ██╔══██╗██║ ██║██║███╗██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██╔══██║██║ ██║ -# ██████╔╝╚██████╔╝╚███╔███╔╝██████╔╝███████╗██║ ╚████║ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -bowden_homing_max: 2000 # Maximum attempted bowden move (for calibration). Should be larger than your actual bowden! - -# If you MMU is equiped with an encoder the following options are available: -# -# In addition to different bowden loading speeds for buffer and non-buffered filament it is possible to detect missed -# steps caused by "jerking" on a heavy spool. If bowden correction is enabled Happy Hare will "believe" the encoder -# reading and make correction moves to bring the filament to within the 'bowden_allowable_load_delta' of the end of -# bowden position (this does require a reliable encoder and is not recommended for very high speed loading >350mm/s) -# -bowden_apply_correction: 0 # 1 to enable, 0 disabled -bowden_allowable_load_delta: 20.0 # How close in mm the correction moves will attempt to get to target -# -# This saftey check uses the encoder to verify the filament is free of extruder before the fast bowden movement to -# reduce possibility of grinding filament. If enabled the trigger can be tuned by setting the "error tolerance" which -# represents the fraction of allowable mismatch between actual movement and that seen by encoder. Setting to 50% tolerance -# usually works well. Increasing will make test more tolerant. Value of 100% essentially disables error detection -# -bowden_pre_unload_test: 1 # 1 to check for bowden movement before full pull (slower), 0 don't check (faster) -bowden_pre_unload_error_tolerance: 50 # ADVANCED: tune pre_unload_test - - -# Extruder homing ----------------------------------------------------------------------------------------------------- -# ███████╗██╗ ██╗████████╗ ██╗ ██╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ -# ██╔════╝╚██╗██╔╝╚══██╔══╝ ██║ ██║██╔═══██╗████╗ ████║██║████╗ ██║██╔════╝ -# █████╗ ╚███╔╝ ██║ ███████║██║ ██║██╔████╔██║██║██╔██╗ ██║██║ ███╗ -# ██╔══╝ ██╔██╗ ██║ ██╔══██║██║ ██║██║╚██╔╝██║██║██║╚██╗██║██║ ██║ -# ███████╗██╔╝ ██╗ ██║██╗ ██║ ██║╚██████╔╝██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ -# ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# Happy Hare needs a reference "homing point" close to the extruder from which to accurately complete the loading of -# the toolhead. This homing operation takes place after the fast bowden load and it is anticipated that that load -# operation will leave the filament just shy of the homing point. If using a toolhead sensor this initial extruder -# homing is unnecessary (but can be forced) because the homing will occur inside the extruder for the optimum in accuracy. -# You still should set this homing method because it is also used for the determination and calibration of bowden length. -# -# In addition to an entry sensor "extruder" it is possible for Happy Hare to "feel" for the extruder gear entry -# by colliding with it. This can be done with encoder based collision detection, the compression of the sync-feedback -# (aka buffer) sensor or using "touch" (stallguard) on the gear stepper. Note that encoder collision detection is not -# completely deterministic and you will have to find the sweetspot for your setup by adjusting the TMC current reduction. -# Note that reduced current during collision detection can also prevent unecessary filament griding. -# -# Possible extruder_homing_endtop names: -# collision - Detect the collision with the extruder gear by monitoring encoder movement (Requires encoder) -# Fast bowden load will move to the extruder gears -# mmu_gear_touch - Use touch detection when the gear stepper hits the extruder (Requires stallguard) -# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home -# extruder - If you have a "filament entry" endstop configured (Requires 'extruder' endstop) -# Fast bowden load will move to extruder_homing_buffer distance before sensor, then home -# filament_compression - If you have a "sync-feedback" sensor with compression switch configured -# Fast bowden load will move to extruder_homing_buffer distance before extruder gear, then home -# none - Don't attempt to home. Only possibiliy if lacking all sensor options -# Fast bowden load will move to the extruder gears. Option is fine if using toolhead sensor -# Note: The homing_endstop will be ignored ("none") if a toolhead sensor is available unless "extruder_force_homing: 1" -# -extruder_homing_max: 80 # Maximum distance to advance in order to attempt to home the extruder -extruder_homing_endstop: collision # Filament homing method/endstop name (fallback if toolhead sensor not available) -extruder_homing_buffer: 25 # Amount to reduce the fast bowden load so filament doesn't overshoot the extruder homing point -extruder_collision_homing_current: 30 # % gear_stepper current (10%-100%) to use when homing to extruder homing (100 to disable) - -# If you have a toolhead sensor it will always be used as a homing point making the homing outside of the extruder -# potentially unnecessary. However you can still force this initial homing step by setting this option in which case -# the filament will home to the extruder and then home to the toolhead sensor in two steps -# -extruder_force_homing: 0 - - -# Toolhead loading and unloading -------------------------------------------------------------------------------------- -# ████████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗███████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██████╗ -# ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██║ ██║██╔════╝██╔══██╗██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗ -# ██║ ██║ ██║██║ ██║██║ ███████║█████╗ ███████║██║ ██║ ██║ ██║ ██║███████║██║ ██║ -# ██║ ██║ ██║██║ ██║██║ ██╔══██║██╔══╝ ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║ ██║ -# ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║███████╗██║ ██║██████╔╝ ███████╗╚██████╔╝██║ ██║██████╔╝ -# ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ -# -# It is possible to define highly customized loading and unloading sequences, however, unless you have a specialized -# setup it is probably easier to opt for the built-in toolhead loading and unloading sequence which already offers a -# high degree of customization. If you need even more control then edit the _MMU_LOAD_SEQUENCE and _MMU_UNLOAD_SEQUENCE -# macros in mmu_sequence.cfg - but be careful! -# -# An MMU must have a known point at the end of the bowden from which it can precisely load the extruder. Generally this -# will either be the extruder entrance (which is controlled with settings above) or by homing to toolhead sensor. If -# you have toolhead sensor it is past the extruder gear and the driver needs to know the max distance (from end of -# bowden move) to attempt homing -# -toolhead_homing_max: 40 # Maximum distance to advance in order to attempt to home to defined homing endstop - -# IMPORTANT: These next three settings are based on the physical dimensions of your toolhead -# Once a homing position is determined, Happy Hare needs to know the final move distance to the nozzle. There is only -# one correct value for your setup - use 'toolhead_ooze_reduction' (which corresponds to the residual filament left in -# your nozzle) to control excessive oozing on load. See doc for table of proposed values for common configurations. -# -# NOTE: If you have a toolhead sensor you can automate the calculation of these parameters! Read about the -# `MMU_CALIBRATE_TOOLHEAD` command (https://github.com/moggieuk/Happy-Hare/wiki/Blobbing-and-Stringing#---calibrating-toolhead) -# -toolhead_extruder_to_nozzle: 72 # Distance from extruder gears (entrance) to nozzle -toolhead_sensor_to_nozzle: 62 # Distance from toolhead sensor to nozzle (ignored if not fitted) -toolhead_entry_to_extruder: 8 # Distance from extruder "entry" sensor to extruder gears (ignored if not fitted) - -# This setting represents how much residual filament is left behind in the nozzle when filament is removed, it is thus -# used to reduce the extruder loading length and prevent excessive blobbing but also in the calculation of purge volume. -# Note that this value can also be measured with the `MMU_CALIBRATE_TOOLHEAD` procedure -# -toolhead_residual_filament: 0 # Reduction in extruder loading length because of residual filament left behind - -# TUNING: Finally, this is the last resort tuning value to fix blobbing. It is expected that this value is NEAR ZERO as -# it represents a further reduction in extruder load length to fix blobbing. If using a wipetower and you experience blobs -# on it, increase this value (reduce the quantity of filament loaded). If you experience gaps, decrease this value. If gaps -# and already at 0 then perhaps the 'toolhead_extruder_to_nozzle' or 'toolhead_residual_filament' settings are incorrect. -# Similarly a value >+5mm also suggests the four settings above are not correct. Also see 'retract' setting in -# 'mmu_macro_vars.cfg' for final in-print ooze tuning. -# -toolhead_ooze_reduction: 0 # Reduction in extruder loading length to prevent ooze (represents filament remaining) - -# Distance added to the extruder unload movement to ensure filament is free of extruder. This adds some degree of tolerance -# to slightly incorrect configuration or extruder slippage. However don't use as an excuse for incorrect toolhead settings -# -toolhead_unload_safety_margin: 10 # Extra movement safety margin (default: 10mm) - -# If not synchronizing gear and extruder and you experience a "false" clog detection immediately after the tool change -# it might be because of a long bowden and/or large internal diameter that causes slack in the filament. This optional -# move will tighten the filament after a load by % of current clog detection length. Gear stepper will run at 50% current -# -toolhead_post_load_tighten: 60 # % of clog detection length, 0 to disable. Ignored if 'sync_to_extruder: 1' - -# If synchronizing gear and extruder and you have a sync-feedback "buffer" this setting determines whether to use it -# to create neutral tension after loading -toolhead_post_load_tension_adjust: 1 # 1 to enable (recommended), 0 to disable - -# If sync-feedback compression sensor is available this test will ensure the filament passes the extruder entry by checking -# for neutral tension when moving filament with just the extruder. Recommended with sprung loaded sync-feedback buffers. -# This is ignored if toolhead sensor is available. -toolhead_entry_tension_test: 1 # 1 to enable (recommended), 0 to disable - -# ADVANCED: Controls the detection of successful extruder load/unload movement and represents the fraction of allowable -# mismatch between actual movement and that seen by encoder. Setting to 100% tolerance effectively turns off checking. -# Some designs of extruder have a short move distance that may not be picked up by encoder and cause false errors. This -# allows masking of those errors. However the error often indicates that your extruder load speed is too high or the -# friction is too high on the filament and in that case masking the error is not a good idea. Try reducing friction -# and lowering speed first! -# -toolhead_move_error_tolerance: 60 - - -# Tip forming --------------------------------------------------------------------------------------------------------- -# ████████╗██╗██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗██╗███╗ ██╗ ██████╗ -# ╚══██╔══╝██║██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ -# ██║ ██║██████╔╝ █████╗ ██║ ██║██████╔╝██╔████╔██║██║██╔██╗ ██║██║ ███╗ -# ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██║ ██║ -# ██║ ██║██║ ██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║██║ ╚████║╚██████╔╝ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# Tip forming responsibility can be split between slicer (in-print) and standalone macro (not in-print) or forced to always -# be done by Happy Hare's standalone macro. Since you always need the option to form tips without the slicer so it is -# generally easier to completely turn off the slicer, force "standalone" tip forming and tune only in Happy Hare. -# -# When Happy Hare is asked to form a tip it will run the referenced macro. Two are reference examples are provided but -# you can implement your own: -# _MMU_FORM_TIP .. default tip forming similar to popular slicers like Superslicer and Prusaslicer -# _MMU_CUT_TIP .. for Filametrix (originally ERCFv2) or similar style toolhead filament cutting system -# -# NOTE: For MMU located cutting like the optional EREC cutter you should set still this to _MMU_FORM_TIP to build a decent -# tip prior to extraction and cutting after the unload. -# -# Often it is useful to increase the extruder current for the rapid movement to ensure high torque and no skipped steps -# -# If opting for slicer tip forming you MUST configure where the slicer leaves the filament in the extruder since -# there is no way to determine this. This can be ignored if all tip forming is performed by Happy Hare -# -force_form_tip_standalone: 1 # 0 = Slicer in print else standalone, 1 = Always standalone tip forming (TURN SLICER OFF!) -form_tip_macro: _MMU_FORM_TIP # Name of macro to call to perform the tip forming (or cutting) operation -extruder_form_tip_current: 100 # % of extruder current (100%-150%) to use when forming tip (100 to disable) -slicer_tip_park_pos: 0 # This specifies the position of filament in extruder after slicer completes tip forming - - -# Purging ------------------------------------------------------------------------------------------------------------- -# ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ -# ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██║████╗ ██║██╔════╝ -# ██████╔╝██║ ██║██████╔╝██║ ███╗██║██╔██╗ ██║██║ ███╗ -# ██╔═══╝ ██║ ██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║ -# ██║ ╚██████╔╝██║ ██║╚██████╔╝██║██║ ╚████║╚██████╔╝ -# ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ -# -# After a toolchange it is necessary to purge the old filament. Similar to tip forming this can be done by the slicer and/or -# by Happy Hare using an extension like Blobifer. If a purge_macro is defined it will be called when not printing or whenever -# the slicer isn't going to purge (like initial tool load). You can force it to always be called in a print by setting -# force_purge_standalone, but remember to turn off the slicer wipetower -# -# The default is for no (empty) macro so purging will not be done out of a print and thus wipetower. Two options are shipped with -# Happy Hare but you can also build your own custom one: -# _MMU_PURGE .. default purging that just dumps the desired amount of filament (setup correct parking before enabling this!) -# BLOBIFER .. for excellent Blobifer addon (https://github.com/Dendrowen/Blobifier) -# -# Often it is useful to increase the extruder current for the often rapid puring movement to ensure high torque and no skipped steps -# -force_purge_standalone: 0 # 0 = Slicer wipetower in print else standalone, 1 = Always standalone purging (TURN WIPETOWER OFF!) -purge_macro: _MMU_PURGE # Name of macro to call to perform the standalone purging operation. E.g. BLOBIFIER, _MMU_PURGE -extruder_purge_current: 100 # % of extruder current (100%-150%) to use when purging (100 to disable) - - -# Synchronized gear/extruder movement ---------------------------------------------------------------------------------- -# ███╗ ███╗ ██████╗ ████████╗ ██████╗ ██████╗ ███████╗██╗ ██╗███╗ ██╗ ██████╗ -# ████╗ ████║██╔═══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██╔════╝╚██╗ ██╔╝████╗ ██║██╔════╝ -# ██╔████╔██║██║ ██║ ██║ ██║ ██║██████╔╝ ███████╗ ╚████╔╝ ██╔██╗ ██║██║ -# ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║██╔══██╗ ╚════██║ ╚██╔╝ ██║╚██╗██║██║ -# ██║ ╚═╝ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║ ███████║ ██║ ██║ ╚████║╚██████╗ -# ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ -# -# This controls whether the extruder and gear steppers are synchronized during printing operations -# If you normally run with maxed out gear stepper current consider reducing it with 'sync_gear_current' -# If equipped with TMC drivers the current of the gear and extruder motors can be controlled to optimize performance. -# This can be useful to control gear stepper temperature when printing with synchronized motor -# -sync_gear_current: 70 # % of gear_stepper current (10%-100%) to use when syncing with extruder during print - -# Optionally it is possible to leverage feedback from a "compression/expansion" sensor (aka "buffer") in the bowden -# path from MMU to extruder to ensure that the two motors are kept in sync as viewed by the filament (the signal feedback -# state can be binary supplied by one or two switches: -1 (expanded) and 1 (compressed) of proportional value between -# -1.0 and 1.0. -# -# If only "one half" of the sync-feedback is available (either compression-only or tension-only) then the rotation -# distance is always shifted based on the high/low multipliers, however if both tension and compression are available -# then the rotation distance will autotune to correct setting (recommend you also enable 'autotune_rotation_distance: 1' -# Note that proportional feedback sensors are continuously dynamic -# -# Possible buffer setups, forth option for type where neutral is when both sensors are active: -# -# <------maxrange------> <------maxrange------> <------maxrange------> <------maxrange------> -# <--range---> <----range-----> <----range-----> <> range=0 -# |====================| |====================| |====================| |====================| -# ^ ^ ^ ^ ^^ -# compression tension compression-only tension-only -# -sync_feedback_enabled: 0 # Turn off even if sensor is installed and active -sync_feedback_buffer_range: 6 # Travel in "buffer" between compression/tension or one sensor and end (see above) -sync_feedback_buffer_maxrange: 12 # Absolute maximum end-to-end travel (mm) provided by buffer (see above) -sync_feedback_speed_multiplier: 5 # % "twolevel" gear speed delta to keep filament neutral in buffer (recommend 5%) -sync_feedback_boost_multiplier: 3 # % "twolevel" extra gear speed boost for finding initial neutral position (recommend 3%) -sync_feedback_extrude_threshold: 5 # Extruder movement (mm) for updates (keep small but set > retract distance) - -# If defined this forces debugging to a telemetry log file "sync_.jsonl". This is great if trying to tune clog/tangle -# detection or for getting help on the Happy Hare forum. To plot graph of sync-feedback operation, run: -# ~/Happy-Hare/utils/plot_sync_feedback.sh -# -sync_feedback_debug_log: 0 # 0 = disable (normal opertion), 1 = enable telemetry log (for debugging) - - -# ESpooler control ----------------------------------------------------------------------------------------------------- -# ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ -# ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ -# █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ -# ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ -# ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ -# ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ -# -# If your MMU has a dc motor (often N20) controlled respooler/assist then how it operates can be controlled with these -# settings. Typically the espooler will be controlled with PWM signal. This will be at the maximum at speeds equal or -# above 'espooler.max_stepper_speed'. The PWM signal will scale downwards towards 0 for slower speeds. The falloff being -# controlled by the 'espooler_speed_exponent' setting according to this formula and allows for non-linear characteristics -# the DC motor (0.5 is a good starting value). -# -# espooler_pwm = (stepper_speed / espooler_max_stepper_speed) ^ {espooler_speed_exponent} -# -# Regardless of h/w configuration you can enable/disable actions with the 'espooler_operations' list. E.g. remove 'play' to -# turn off operation while printing. Options are: -# -# rewind - when filament is being unloaded under MMU control (aka respool) -# assist - when filament is being loaded under MMU control (% of "rewind" speed but with minimum of "print" power) -# print - while printing. Generally set 'espooler_printing_power' to a low percentage just to allow motor to be turned -# freely or set to 0 to enable/allow "burst" assist movements -# -# If using a digitally controlled espooler motor (not PWM) then you should turn off the "print" mode and set -# 'espooler_min_stepper_speed' to prevent "over movement" -# -espooler_min_distance: 30 # Individual stepper movements less than this distance will not active espooler -espooler_max_stepper_speed: 300 # Gear stepper speed at which espooler will be at maximum power -espooler_min_stepper_speed: 0 # Gear stepper speed at which espooler will become inactive (useful for non PWM control) -espooler_speed_exponent: 0.5 # Controls non-linear espooler power relative to stepper speed (see notes) -espooler_assist_reduced_speed: 50 # Control the % of the rewind speed that is applied to assisting load (want rewind to be faster) -espooler_printing_power: 0 # If >0, fixes the % of PWM power while printing. 0=allows burst movement -espooler_operations: rewind, assist, print # List of operational modes (allows disabling even if h/w is configured) -# -# The following burst configuration is used to control the small rotation in the ASSIST direction optionally used -# when in 'print' operation is enabled, 'espooler_printing_power: 0' and is triggered (tension switch or extruder movement). -# It can also be used to loosen filament with 'MMU_ESPOOLER COMMAND=assist BURST=1' -# -espooler_assist_extruder_move_length: 100 # Distance (mm) extruder needs to move between each assist burst -espooler_assist_burst_power: 100 # The % power of the burst move -espooler_assist_burst_duration: 0.4 # The duration of the burst move is seconds -espooler_assist_burst_trigger: 0 # If trigger assist switch is fitted 0=disable, 1=enable -espooler_assist_burst_trigger_max: 3 # If trigger assist switch is fitted this limits the max number of back-to-back advances -# -# The following burst configuration is used to control the small rotation in the REWIND direction optionally used -# when running running the filament drying cycle. The goal is to rotate the spool 60-90 degrees. It can also be -# used to tighten the filament with 'MMU_ESPOOLER COMMAND=rewind BURST=1' -# -espooler_rewind_burst_power: 100 # The % power of the rewind burst move -espooler_rewind_burst_duration: 0.4 # The duration of the rewind burst move is seconds - - -# Heater / Environment Management ------------------------------------------------------------------------------------ -# ██╗ ██╗███████╗ █████╗ ████████╗███████╗██████╗ -# ██║ ██║██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ -# ███████║█████╗ ███████║ ██║ █████╗ ██████╔╝ -# ██╔══██║██╔══╝ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ -# ██║ ██║███████╗██║ ██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -heater_max_temp: 70 # Absolute max heater setting to protect the MMU enclosure construction (adjust to match material) -heater_default_dry_temp: 45 # Default drying temperature if filament type is not matched in drying_data -heater_default_dry_time: 300 # Default drying cycle time in minutes -heater_default_humidity: 25 # Default humidity % goal. Drying will terminate if this value is reached -heater_vent_macro: _MMU_VENT # Name of macro to periodicaly call during drying cycle -heater_vent_interval: 0 # Interval in minutes to call heater_vent_macro during drying cycle, 0=disable venting -heater_rotate_interval: 5 # Interval in minutes to rotate filament (requires eSpooler and filament end attached to spool) - -# Drying data for MMU_HEATER DRY=1 command in form (material type is case insensitive): -# 'filament_type': (temp, drying_time_mins) -# -# (Careful with formatting of this line - reformatting will break upgrade logic) -# -drying_data: { 'pla': (45, 300), 'pla+': (55, 300), 'petg': (60, 300), 'tpu': (55, 300), 'abs': (70, 300), 'abs+': (75, 300), 'asa': (65, 300), 'nylon': (75, 600), 'pc': (75, 600), 'pva': (75, 600), 'hips': (75, 600) } - - -# FlowGuard Clog and Tangle Detection -------------------------------------------------------------------------------- -# ███████╗██╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ -# ██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗ -# █████╗ ██║ ██║ ██║██║ █╗ ██║██║ ███╗██║ ██║███████║██████╔╝██║ ██║ -# ██╔══╝ ██║ ██║ ██║██║███╗██║██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║ -# ██║ ███████╗╚██████╔╝╚███╔███╔╝╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝ -# ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ -# -# Options are available to automatically detects extruder clogs and MMU tangles. Each option works independently and -# can be combined. Flowguard can even discern the difference between an extruder clog and a spool tangle! -# -# Flowguard: This intelligently measures filament tension (only available if sync-feedback buffer is fitted) -# -# Encoder detection: This monitors encoder movement and compares to extruder (only available if encoder is fitted) -# -flowguard_enabled: 1 # 0 = Flowguard protection disabled, 1 = Enabled - -# The flowguard_max_relief is the amount of relief movement (effective mm change in filament length between MMU and extruder) -# that Happy Hare will wait until triggering a clog or runout. A smaller value is more sensitive to triggering. Since the -# relief movement is hightly dependent on filament "spring" in the bowden tube, filament friction, and -# 'sync_feedback_buffer_range', it is generally good to start high and then decrease if a more sensitive trigger is desired. -# Analog proportional (type P) sensors can generally have a much lower value. Increase if you have false triggers. -flowguard_max_relief: 40 - -# Encoder runout/clog/tangle detection watches for movement over either a static or automatically adjusted distance - if -# no encoder movement is seen when the extruder moves this distance runout/ clog/tangle event will be generated. Allowing -# the distance to be adjusted automatically (mode=2) will generally allow for a quicker trigger but use a static length -# (mode=1, set encoder_max_motion) if you get false triggers (see flowguard guide on wiki for more details). -# Note that this feature cannot disinguish between clog or tangle. -flowguard_encoder_mode: 2 # 0 = Disable, 1 = Static length clog detection, 2 = Automatic length clog detection - -# The encoder_max_motion is the absolute max permitted extruder movement without the encoder seeing movement when using -# status mode (mode=1). Smaller values are more sensitive but beware of going too small - slack and friction in the -# bowden may cause gaps in encoder movement. Increase if you have false triggers. -# Note that this value is overriden by any calibrated value stored in 'mmu_vars.cfg' if in automatic mode (mode=2). -flowguard_encoder_max_motion: 20 - - -# Filament Management Options ---------------------------------------------------------------------------------------- -# ███████╗██╗██╗ ███╗ ███╗ ██████╗ ███╗ ███╗████████╗ -# ██╔════╝██║██║ ████╗ ████║██╔════╝ ████╗ ████║╚══██╔══╝ -# █████╗ ██║██║ ██╔████╔██║██║ ███╗██╔████╔██║ ██║ -# ██╔══╝ ██║██║ ██║╚██╔╝██║██║ ██║██║╚██╔╝██║ ██║ -# ██║ ██║███████╗██╗ ██║ ╚═╝ ██║╚██████╔╝██║ ╚═╝ ██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ -# -# - EndlessSpool feature allows detection of runout on one spool and the automatic mapping of tool to an alternative -# gate (spool). Set to '1', this feature requires clog detection or gate sensor or pre-gate sensors. EndlessSpool -# functionality can optionally be extended to attempt to load an empty gate with 'endless_spool_on_load'. On some MMU -# designs (with linear selector) it can also be configured to eject filament remains to a designated gate rather than -# defaulting to current gate. A custom gate will disable pre-gate runout detection for EndlessSpool because filament -# end must completely pass through the gate for selector to move -# -endless_spool_enabled: 1 # 0 = disable, 1 = enable endless spool -endless_spool_on_load: 0 # 0 = don't apply endless spool on load, 1 = run endless spool if gate is empty -endless_spool_eject_gate: -1 # Which gate to eject the filament remains. -1 = current gate -#endless_spool_groups: # Default EndlessSpool groups (see later in file) -# -# Spoolman support requires you to correctly enable spoolman with moonraker first. If enabled, the gate SpoolId will -# be used to load filament details and color from the spoolman database and Happy Hare will activate/deactivate -# spools as they are used. The enabled variation allows for either the local map or the spoolman map to be the -# source of truth as well as just fetching filament attributes. See this table for explanation: -# -# | Activate/ | Fetch filament attributes | Filament gate | Filament gate | -# spoolman_support | Deactivate | attributes from spoolman | assignment shown | assignment pulled | -# | spool? | based on spool_id? | in spoolman db? | from spoolman db? | -# -----------------+------------+---------------------------+------------------+-------------------+ -# off | no | no | no | no | -# readonly | yes | yes | no | no | -# push | yes | yes | yes | no | -# pull | yes | yes | yes | yes | -# -spoolman_support: off # off = disabled, readonly = enabled, push = local gate map, pull = remote gate map -pending_spool_id_timeout: 20 # Seconds after which this pending spool_id (set with rfid) is voided -# -# Mainsail/Fluid UI can visualize the color of filaments next to the extruder/tool chooser. The color is dynamic and -# can be customized to your choice: -# -# slicer - Color from slicer tool map (what the slicer expects) -# allgates - Color from all the tools in the gate map after running through the TTG map -# gatemap - As per gatemap but hide empty tools -# off - Turns off support -# -# Note: Happy Hare will also add the 'spool_id' variable to the Tx macro if spoolman is enabled -# -t_macro_color: slicer # 'slicer' = default | 'allgates' = mmu | 'gatemap' = mmu without empty gates | 'off' - - -# Print Statistics --------------------------------------------------------------------------------------------------- -# ███████╗████████╗ █████╗ ████████╗███████╗ -# ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝ -# ███████╗ ██║ ███████║ ██║ ███████╗ -# ╚════██║ ██║ ██╔══██║ ██║ ╚════██║ -# ███████║ ██║ ██║ ██║ ██║ ███████║ -# ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ -# -# These parameters determine how print statistic data is shown in the console. This table can show a lot of data, -# probably more than you'd want to see. Below you can enable/disable options to your needs. -# -# +-----------+---------------------+----------------------+----------+ -# | 114(46) | unloading | loading | complete | -# | swaps | pre | - | post | pre | - | post | swap | -# +-----------+------+-------+------+------+-------+-------+----------+ -# | all time | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | -# | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | -# | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | -# | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | -# | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | -# +-----------+------+-------+------+------+-------+-------+----------+ -# Note: Only formats correctly on Python3 -# -# Comma separated list of desired columns -# Options: pre_unload, form_tip, unload, post_unload, pre_load, load, purge, post_load, total -console_stat_columns: unload, load, post_load, total - -# Comma separated list of rows. The order determines the order in which they're shown. -# Options: total, total_average, job, job_average, last -console_stat_rows: total, total_average, job, job_average, last - -# How you'd want to see the state of the gates and how they're performing -# string - poor, good, perfect, etc.. -# percentage - rate of success -# emoticon - fun sad to happy faces (python3 only) -console_gate_stat: emoticon - -# Always display the full statistics table -console_always_output_full: 1 # 1 = Show full table, 0 = Only show totals out of print - - -# Calibration and autotune ------------------------------------------------------------------------------------------- -# ██████╗ █████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ -# ██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ -# ██║ ███████║██║ ██║██████╔╝██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ -# ██║ ██╔══██║██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ -# ╚██████╗██║ ██║███████╗██║██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ -# ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ -# -# These are auto calibration/tuning settings that can be used to ease initial setup and/or to tune calibration over -# time based on measured telemetry. Whether these auto-tuning features are available depends on MMU design and -# configured sensors (explained below). The setting will be ignored if the required sensors are not available but if -# they can operate they will suppress the normal calibration warnings (MMU_STATUS can still be used to view them). -# Note that these are initially set by the installer to recommended values -# -# autocal_bowden_length - the calibrated bowden length will be established on first load. It can also be set -# manually or reset with MMU_CALIBRATE_BOWDEN. Best results require the use of -# sync-feedback-compression or extruder sensor but gear-touch or encoder will also work. -# 'extruder_homing_endstop' cannot be 'none' -# autotune_bowden_length - Once calibrated this setting will tune the bowden distance over time. Works best with -# toolhead sensor -# skip_cal_rotation_distance - This will rely on installed default value (although it can still be calibrated). Usually -# a good choice if autotune is enabled -# autotune_rotation_distance - Requires sync-feedback sensor (aka "buffer") or calibrated encoder. If set then either the -# "autotuner" (sync-feedback buffer) or encoder telemetry will be used to adjust the -# persisted gear rotation distance. -# skip_cal_encoder - Will rely on installed default value (although it can still be calibrates). -# Not recommended but allows for easier initial setup especially when 'autotune_encoder' -# is enabled. -# autotune_encoder - NOT IMPLEMENTED YET. Soon! -# -autocal_bowden_length: 1 # Automated bowden length calibration. 1=automatic, 0=manual/off -autotune_bowden_length: 1 # Automated bowden length tuning. 1=on, 0=off -skip_cal_rotation_distance: 0 # Skip rotation distance calibration (MMU_CALIBRATE_GEAR), 1=skip, 0=require -autotune_rotation_distance: 0 # Automated gate calibration/tuning. 1=automatic, 0=manual/off -skip_cal_encoder: 0 # Skip encoder calibration (MMU_CALIBRATE_ENCODER), 1=skip, 0=require -autotune_encoder: 0 # Automated encoder tuning. 1=automatic, 0=manual/off - - -# Miscellaneous, but you should review ------------------------------------------------------------------------------- -# ███╗ ███╗██╗███████╗ ██████╗ -# ████╗ ████║██║██╔════╝██╔════╝ -# ██╔████╔██║██║███████╗██║ -# ██║╚██╔╝██║██║╚════██║██║ -# ██║ ╚═╝ ██║██║███████║╚██████╗ -# ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ -# -# Important you verify these work for you setup/workflow. Temperature and timeouts -# -timeout_pause: 72000 # Idle time out (printer shuts down) in seconds used when in MMU pause state -disable_heater: 600 # Delay in seconds after which the hotend heater is disabled in the MMU_PAUSE state -default_extruder_temp: 200 # Default temperature for performing swaps and forming tips when not in print (overridden by gate map) -extruder_temp_variance: 2 # When waiting for extruder temperature this is the +/- permissible variance in degrees (>= 1) -# -# Other workflow options -# -startup_home_if_unloaded: 0 # 1 = force mmu homing on startup if unloaded, 0 = do nothing -startup_reset_ttg_map: 0 # 1 = reset TTG map on startup, 0 = do nothing -show_error_dialog: 1 # 1 = show pop-up dialog in addition to console message, 0 = show error in console -preload_attempts: 5 # How many "grabbing" attempts are made to pick up the filament with preload feature -strict_filament_recovery: 0 # If enabled with MMU with toolhead sensor, this will cause filament position recovery to - # perform extra moves to look for filament trapped in the space after extruder but before sensor -filament_recovery_on_pause: 1 # 1 = Run a quick check to determine current filament position on pause/error, 0 = disable -retry_tool_change_on_error: 0 # Whether to automatically retry a failed tool change. If enabled Happy Hare will perform - # the equivalent of 'MMU_RECOVER' + 'Tx' commands which usually is all that is necessary - # to recover. Note that enabling this can mask problems with your MMU -bypass_autoload: 1 # If extruder sensor fitted this controls the automatic loading of extruder for bypass operation -has_filament_buffer: 1 # Whether the MMU has a filament buffer. Set to 0 if using Filamentalist or DC eSpooler, etc -# -# Advanced options. Don't mess unless you fully understand. Read documentation. -# -encoder_move_validation: 1 # ADVANCED: 1 = Normally Encoder validates move distances are within given tolerance - # 0 = Validation is disabled (eliminates slight pause between moves but less safe) -print_start_detection: 1 # ADVANCED: Enabled for Happy Hare to automatically detect start and end of print and call - # ADVANCED: MMU_PRINT_START and MMU_PRINT_END automatically. Harmless to leave enabled but can disable - # if you think it is causing problems and known START/END is covered in your macros -extruder: extruder # ADVANCED: Name of the toolhead extruder that MMU is using -gcode_load_sequence: 0 # VERY ADVANCED: Gcode loading sequence 1=enabled, 0=internal logic (default) -gcode_unload_sequence: 0 # VERY ADVANCED: Gcode unloading sequence, 1=enabled, 0=internal logic (default) - - -# ADVANCED: Klipper tuning ------------------------------------------------------------------------------------------- -# ██╗ ██╗██╗ ██╗██████╗ ██████╗ ███████╗██████╗ -# ██║ ██╔╝██║ ██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ -# █████╔╝ ██║ ██║██████╔╝██████╔╝█████╗ ██████╔╝ -# ██╔═██╗ ██║ ██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ -# ██║ ██╗███████╗██║██║ ██║ ███████╗██║ ██║ -# ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -# -# Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing -# operations especially so with CANbus connected MCUs. Happy Hare uses many homing moves for reliable extruder loading -# and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error -# -update_trsync: 0 # 1 = Increase TRSYNC_TIMEOUT, 0 = Leave the klipper default -# -# Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms timeout will kill -# the print. Since it seems to occur only on homing moves they can be safely retried to workaround. This has been -# working well in practice -canbus_comms_retries: 3 # Number of retries. Recommend the default of 3. -# -# Older neopixels have very finicky timing and can generate lots of "Unable to obtain 'neopixel_result' response" -# errors in klippy.log. An often cited workaround is to increase BIT_MAX_TIME in neopixel.py. This option does that -# automatically for you to save dirtying klipper -update_bit_max_time: 1 # 1 = Increase BIT_MAX_TIME, 0 = Leave the klipper default -# -# BTT ViViD used a AHT30 sensor. If you are using an older klipper you may not have this sensor available. If so, use -# AHT10 and set this to 1 to convert AHT30 commands to AHT10 -update_aht10_commands: 0 # 1 = Config AHT10 for BTT ViViD heater sensor on older klipper, 0 = Leave the klipper default - - -# ADVANCED: MMU macro overrides --- ONLY SET IF YOU'RE COMFORTABLE WITH KLIPPER MACROS ------------------------------- -# ███╗ ███╗ █████╗ ██████╗██████╗ ██████╗ ███████╗ -# ████╗ ████║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔════╝ -# ██╔████╔██║███████║██║ ██████╔╝██║ ██║███████╗ -# ██║╚██╔╝██║██╔══██║██║ ██╔══██╗██║ ██║╚════██║ -# ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║╚██████╔╝███████║ -# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ -# -# 'pause_macro' defines what macro to call on MMU error (must put printer in paused state) -# Other macros are detailed in 'mmu_sequence.cfg' -# Also see form_tip_macro in Tip Forming section and purge_macro in Purging section -# -pause_macro: PAUSE # What macro to call to pause the print -action_changed_macro: _MMU_ACTION_CHANGED # Called when action (printer.mmu.action) changes -print_state_changed_macro: _MMU_PRINT_STATE_CHANGED # Called when print state (printer.mmu.print_state) changes -mmu_event_macro: _MMU_EVENT # Called on useful MMU events -pre_unload_macro: _MMU_PRE_UNLOAD # Called before starting the unload -post_form_tip_macro: _MMU_POST_FORM_TIP # Called immediately after tip forming -post_unload_macro: _MMU_POST_UNLOAD # Called after unload completes -pre_load_macro: _MMU_PRE_LOAD # Called before starting the load -post_load_macro: _MMU_POST_LOAD # Called after the load is complete -unload_sequence_macro: _MMU_UNLOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_unload_sequence' -load_sequence_macro: _MMU_LOAD_SEQUENCE # VERY ADVANCED: Optionally called based on 'gcode_load_sequence' - - -# ADVANCED: See documentation for use of these ----------------------------------------------------------------------- -# ██████╗ ███████╗███████╗███████╗████████╗ ██████╗ ███████╗███████╗███████╗ -# ██╔══██╗██╔════╝██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██╔════╝██╔════╝██╔════╝ -# ██████╔╝█████╗ ███████╗█████╗ ██║ ██║ ██║█████╗ █████╗ ███████╗ -# ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║ ██║ ██║██╔══╝ ██╔══╝ ╚════██║ -# ██║ ██║███████╗███████║███████╗ ██║ ██████╔╝███████╗██║ ███████║ -# ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ -# -# These are the values that the various "RESET" commands will reset too rather than the built-in defaults. The lenght -# of the lists must match the number of gates on your MMU -# -# e.g. MMU_GATE_MAP RESET=1 - will use all the 'gate_XXX' values -# MMU_TTG_MAP RESET=1 - will use the 'tool_to_gate_map' -# MMU_ENDLESS_SPOOL_GROUPS RESET=1 - will use the 'endless_spool_groups' -# -# Gate: #0 #1 #2 #3 #4 #5 #6 #7 #8 -#gate_status: 1, 0, 1, 2, 2, -1, -1, 0, 1 -#gate_filament_name: one, two, three, four, five, six, seven, eight, nine -#gate_material: PLA, ABS, ABS, ABS+, PLA, PLA, PETG, TPU, ABS -#gate_color: red, black, yellow, green, blue, indigo, ffffff, grey, black -#gate_temperature: 210, 240, 235, 245, 210, 200, 215, 240, 240 -#gate_spool_id: 3, 2, 1, 4, 5, 6, 7, -1, 9 -#gate_speed_override: 100, 100, 100, 100, 100, 100, 100, 50, 100 -#endless_spool_groups: 0, 1, 2, 1, 0, 0, 3, 4, 1 -# -# Tool: T0 T1 T2 T3 T4 T5 T6 T7 T8 -#tool_to_gate_map: 0, 1, 2, 3, 4, 5, 6, 7, 8 diff --git a/klippy/extras/Happy-Hare/config/base/mmu_purge.cfg b/klippy/extras/Happy-Hare/config/base/mmu_purge.cfg deleted file mode 100644 index b2a23c37e574..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_purge.cfg +++ /dev/null @@ -1,95 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Standalone (very simplistic reference) filament purging -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# When using this macro in print it is important to turn off the wipetower in your slicer -# (read the wiki: Slicer Setup & Toolchange-Movement pages) -# Then set the following parameters in mmu_parameters.cfg: -# -# purge_macro: _MMU_PURGE -# force_purge_standalone: 1 -# -[gcode_macro _MMU_PURGE] -description: Simple reference filament purge - -gcode: - # Happy Hare retraction settings from sequence macros - {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set retracted_length = park_vars.retracted_length %} - {% set retract_speed = sequence_vars.retract_speed|int %} - {% set unretract_speed = sequence_vars.unretract_speed|int %} - - # Happy Hare provided purge data - {% set toolchange_purge_volume = printer.mmu.toolchange_purge_volume|default(0)|float %} - {% set extruder_filament_remaining = printer.mmu.extruder_filament_remaining|default(0)|float %} - - # Not used in reference macro but full purge volume matrix from the slicer can be loaded like this - # https://github.com/moggieuk/Happy-Hare/wiki/Gcode-Preprocessing) - {% set pv = printer.mmu.slicer_tool_map.purge_volumes %} - - # Calculate amount of filament to purge - {% set filament_diameter = printer.configfile.config.extruder.filament_diameter|float %} - {% set filament_cross_section = (filament_diameter / 2) ** 2 * 3.1415 %} - {% set purge_len = toolchange_purge_volume / filament_cross_section %} - {% set segment_len = 2.0 %} - - # Undo Happy Hare retraction before starting purge - {% if retracted_length > 0 %} - MMU_LOG MSG="Un-retracting {retracted_length}mm" - M83 ; Extruder relative - G1 E{retracted_length} F{unretract_speed|abs * 60} - {% endif %} - - {% if extruder_filament_remaining > 0 %} - MMU_LOG MSG="Purging {purge_len | round(1)}mm of filament including {extruder_filament_remaining | round(1)}mm fragment remaining in extruder (Total volume {(filament_cross_section * purge_len) | round(1)}mm³)" - {% else %} - MMU_LOG MSG="Purging {purge_len | round(1)}mm of filament (Total volume {(filament_cross_section * purge_len) | round(1)}mm³)" - {% endif %} - - # Purge in segments so it is still possible to detect clogs and pause - {% set num_segments = (purge_len // segment_len) | int %} - {% for _ in range(num_segments) %} - __MMU_PURGE_SEGMENT LENGTH={segment_len} - {% endfor %} - __MMU_PURGE_SEGMENT LENGTH={purge_len % segment_len} - - # Retract to match what Happy Hare is expecting - {% if retracted_length > 0 %} - MMU_LOG MSG="Retracting {retracted_length}mm" - M83 ; Extruder relative - G1 E-{retracted_length} F{retract_speed|abs * 60} - {% endif %} - - # Adjust tension and centre sensor to ensure we don't accidentally trigger FlowGuard runout when print resumes - {% if printer.mmu.sync_feedback_enabled %} - MMU_SYNC_FEEDBACK ADJUST_TENSION=1 - {% endif %} - - MMU_LOG MSG="Purging complete" - - -# Helper that allows for check of "runout/clog" indicator -[gcode_macro __MMU_PURGE_SEGMENT] -gcode: - {% set vars = printer['gcode_macro _MMU_PURGE_VARS'] %} - {% set extruder_purge_speed = vars['extruder_purge_speed']|float %} - {% set length = params.LENGTH|float %} - {% set clog_runout_detected = printer.mmu.clog_runout_detected|default(false)|lower == 'true' %} # TODO Future - - {% if not clog_runout_detected %} - G1 E{length} F{extruder_purge_speed * 60} - {% endif %} diff --git a/klippy/extras/Happy-Hare/config/base/mmu_sequence.cfg b/klippy/extras/Happy-Hare/config/base/mmu_sequence.cfg deleted file mode 100644 index 9f3ce822bfa2..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_sequence.cfg +++ /dev/null @@ -1,665 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Control of parking and loading and unload sequences -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# These skeleton macros define all the callbacks made during filament loading or unloading. They can be extended with -# the user command additions (see 'mmu_macro_vars.cfg') or can be used as templates for completely custom macros. Note -# the SAVE/RESTORE_GCODE_STATE wrapper pattern is precautionary -# -# The ordering of these macros is as follows (if any are not defined they are skipped): -# -# Unloading sequence... -# _MMU_PRE_UNLOAD Called before starting the unload -# 'form_tip_macro' User defined macro for tip forming -# _MMU_POST_FORM_TIP Called immediately after tip forming -# (_MMU_UNLOAD_SEQUENCE) Advanced: Optionally called based on 'gcode_unload_sequence' -# _MMU_POST_UNLOAD Called after unload completes -# -# Loading sequence... -# _MMU_PRE_LOAD Called before starting the load -# (_MMU_LOAD_SEQUENCE) Advanced: Optionally called based on 'gcode_load_sequence' -# _MMU_POST_LOAD Called after the load is complete -# -# If changing a tool the unload sequence will be immediately followed by the load sequence -# -# Notes: -# 1. When the MMU is enabled Happy Hare implement z-hop and retraction portion of the moves on toolchange, runout, -# pauses and mmu errors (while in print) as well as the un-retraction and return to print positioning. The reason -# for this is print quality, speed and safety. However the configuration is still defined in mmu_macro_vars. -# 2. Pressure advance will automatically be cleared prior and restored after tip forming -# 3. M220 & M221 overrides will be retained after a toolchange -# 4. If configured, Spoolman will be notified of toolchange -# 5. Should an error occur causing a pause, the extruder temp will be saved and restored on MMU_UNLOCK or resume -# -# When the MMU is disabled, the supplied client_macros.cfg will take over retraction and z-hop moves using the -# same configuration which is why they are recommended rather than using alternatives -# -# Leveraging the user defined macro callbacks is usually sufficient for customization, however if you really want to -# do something unusual you can enable the gcode loading/unloading sequences by setting the following in 'mmu_parameters.cfg' -# -# 'gcode_load_sequence: 1' -# 'gcode_unload_sequence: 1' -# -# This is quite advanced and you will need to understand the Happy Hare state machine before embarking on changes. -# Reading the doc is essential -# - - -########################################################################### -# Shared toolhead parking macro designed to position toolhead at a suitable -# parking position, manage z-hops and retraction -# -# Standalone use, typically: _MMU_PARK FORCE_PARK=1 X= Y= Z_HOP= -# -# FORCE_PARK=1 - force parking move -# X | float - x coordinate of forced parking location -# Y | float - y coordinate of forced parking location -# Z_HOP | float - z-hop for forced parking move -# -# OPERATION | string - define operation being performed for built-in parking -# -[gcode_macro _MMU_PARK] -description: Park toolhead safely away from print - -# -------------------------- Internal Don't Touch ------------------------- -variable_saved_xyz: 0, 0, 0 -variable_saved_pos: False # Saved pos valid? -variable_park_operation: '' # If parked, what operation was responsible -variable_next_xy: 0, 0 # Next x,y pos if next_pos is True -variable_next_pos: False # Restore to next_pos? -variable_min_lifted_z: 0 # Supports rising "z-lifted floor" for sequential printing -variable_toolchange_z: -1 # Current calculated toolchange/movement plane -variable_retracted_length: 0 # Amount of current retraction - -gcode: - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set operation = params.OPERATION|default(printer.mmu.operation)|string|lower %} - {% set is_printing = printer.mmu.print_state in ["started", "printing"] and not printer.pause_resume.is_paused %} - {% set force_park = params.FORCE_PARK|default(0)|int == 1 %} - {% set force_park_x = params.X|default(-999)|float %} - {% set force_park_y = params.Y|default(-999)|float %} - {% set enable_park_printing = (vars.enable_park_printing|default("toolchange,runout,pause,cancel")|string|lower).split(",") %} - {% set enable_park_standalone = (vars.enable_park_standalone|default("toolchange,load,unload,pause,cancel")|string|lower).split(",") %} - {% set enable_park_disabled = (vars.enable_park_disabled|default("pause,cancel")|string|lower).split(",") %} - {% set nx,ny = next_xy|map('float') %} - {% set pos = printer.gcode_move.gcode_position %} - {% if force_park %} - {% set operation = 'force_park' %} - {% set x = force_park_x %} - {% set y = force_park_y %} - {% set park_z_hop = params.Z_HOP|default(0)|float %} - {% set z_hop_ramp = params.RAMP|default(0)|float %} - {% set retract = 0 %} - {% elif operation in ['toolchange','load','unload'] %} - {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_toolchange|default([-999,-999,0,0,0])|map('float') %} - {% elif operation in ['runout'] %} - {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_runout|default([-999,-999,0,0,0])|map('float') %} - {% elif operation in ['pause'] %} - {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_pause|default([-999,-999,0,0,0])|map('float') %} - {% elif operation in ['cancel'] %} - {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_cancel|default([-999,-999,0,0,0])|map('float') %} - {% elif operation in ['complete'] %} - {% set x, y, park_z_hop, z_hop_ramp, retract = vars.park_complete|default([-999,-999,0,0,0])|map('float') %} - {% else %} - {% set x = -999 %} - {% set y = -999 %} - {% set park_z_hop = 0 %} - {% set z_hop_ramp = 0 %} - {% set retract = 0 %} - {% endif %} - {% set park_z_hop = park_z_hop|abs %} - {% set z_hop_ramp = z_hop_ramp|abs %} - {% set min_toolchange_z = vars.min_toolchange_z|default(1)|float|abs %} - {% if x == -999 and y == -999 and park_z_hop == 0 %} - {% set min_toolchange_z = 0 %} - {% endif %} - {% set park_travel_speed = vars.park_travel_speed|default(200)|float * 60 %} - {% set park_lift_speed = vars.park_lift_speed|default(15)|float * 60 %} - {% set pos = printer.gcode_move.gcode_position %} - {% set origin = printer.gcode_move.homing_origin %} - {% set max = printer.toolhead.axis_maximum %} - - # Z-hop ramp position calcs - {% set orig_x = pos.x %} - {% set orig_y = pos.y %} - {% set cx = (max.x - origin.x) / 2.0 %} - {% set cy = (max.y - origin.y) / 2.0 %} - {% if pos.x == cx and pos.y == cy %} - {% set target_x = 0 %} - {% set target_y = 0 %} - {% else %} - {% set target_x = cx %} - {% set target_y = cy %} - {% endif %} - {% set dx = target_x - pos.x %} - {% set dy = target_y - pos.y %} - {% set length = (dx * dx + dy * dy) ** 0.5 %} - {% set z_hop_x = pos.x + z_hop_ramp * dx / length %} - {% set z_hop_y = pos.y + z_hop_ramp * dy / length %} - - {% set starting_z = saved_xyz[2] if saved_pos else pos.z %} - {% set z_hop_floor = [starting_z, min_lifted_z]|max %} - {% set toolchange_z = [[z_hop_floor + park_z_hop, max.z - origin.z]|min, min_toolchange_z, toolchange_z]|max %} - - {% set should_park = force_park or ( - operation in ( - enable_park_disabled if not printer.mmu.enabled else - (enable_park_printing if is_printing else enable_park_standalone) - ) and operation != park_operation - ) %} - {% set should_ramp = ( - should_park and pos.z == starting_z and - park_z_hop > 0 and z_hop_ramp > 0 and - operation not in ['load'] and is_printing - ) %} - {% set should_retract = ( - should_park and - retracted_length == 0 and retract > 0 and - operation not in ['load'] - ) %} - - {% if should_park %} - {% if should_retract %} - _MMU_RETRACT LENGTH={retract} - {% endif %} - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=toolchange_z VALUE={toolchange_z} - {% set lift=" with ramping lift" if should_ramp else "" %} - MMU_LOG MSG="Parking toolhead at (x:{x|round(1) if x != -999 else "n/a"}, y:{y|round(1) if y != -999 else "n/a"}, z:{toolchange_z|round(1)}){lift} for {operation} operation" - {% if 'xy' not in printer.toolhead.homed_axes %} - MMU_LOG MSG="Cannot park because XY not homed" - {% else %} - G90 # Absolute - {% if 'z' not in printer.toolhead.homed_axes %} - MMU_LOG MSG="Skipping z_hop move because Z not homed" - {% else %} - {% if should_ramp %} - G1 X{z_hop_x} Y{z_hop_y} Z{toolchange_z} F{park_travel_speed} # Ramping Z lift - G1 X{orig_x} Y{orig_y} Z{toolchange_z} F{park_travel_speed} # Restore starting X,Y - {% else %} - G1 Z{toolchange_z} F{park_lift_speed} # Z lift to toolchange plane - {% endif %} - {% endif %} - {% if vars.user_park_move_macro %} - {vars.user_park_move_macro} X={x} Y={y} F={park_travel_speed} - {% elif x != -999 and y != -999 %} - G1 X{x} Y{y} F{park_travel_speed} # Move to park position - {% elif x != -999 and y == -999 %} - G1 X{x} F{park_travel_speed} # X move only - {% elif x == -999 and y != -999 %} - G1 Y{y} F{park_travel_speed} # Y move only - {% endif %} - {% endif %} - {% if not force_park %} - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=park_operation VALUE=\"{operation}\" - {% endif %} - {% endif %} - - -########################################################################### -# Helper macro: save current toolhead position -# This macro is idempotent recording only the first position until cleared -# This is often called directly by Happy Hare -# -[gcode_macro _MMU_SAVE_POSITION] -description: Record to toolhead position for return later -gcode: - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set pos = printer.gcode_move.gcode_position %} - {% set axis_minimum = printer.toolhead.axis_minimum %} - {% set axis_maximum = printer.toolhead.axis_maximum %} - {% set x = [axis_minimum.x, [axis_maximum.x, pos.x]|min]|max %} - {% set y = [axis_minimum.y, [axis_maximum.y, pos.y]|min]|max %} - - {% if not park_vars.saved_pos and 'xyz' in printer.toolhead.homed_axes %} - {% if x != pos.x or y != pos.y %} - MMU_LOG MSG="Warning: Klipper reported out of range gcode position (x:{pos.x}, y:{pos.y})! Adjusted to (x:{x}, y:{y}) to prevent move failure" ERROR=1 - {% endif %} - MMU_LOG MSG="Saving toolhead position (x:{x|round(1)}, y:{y|round(1)}, z:{pos.z|round(1)})" - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=saved_xyz VALUE="{x}, {y}, {pos.z}" - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=saved_pos VALUE={True} - {% endif %} - - -########################################################################### -# Helper macro: restore previously saved position and reset -# This is often called directly by Happy Hare -# -[gcode_macro _MMU_RESTORE_POSITION] -description: Restore saved toolhead position -gcode: - {% set restore = params.RESTORE|default(1)|int %} - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set x,y,z = park_vars.saved_xyz|map('float') %} - {% set nx,ny = park_vars.next_xy|map('float') %} - {% set park_travel_speed = vars.park_travel_speed|default(200)|float * 60 %} - {% set park_lift_speed = vars.park_lift_speed|default(15)|float * 60 %} - {% set restore_xy_pos = vars.restore_xy_pos|default('last') %} - {% set is_printing = printer.mmu.print_state in ["started", "printing"] and not printer.pause_resume.is_paused %} - - {% set should_unretract = ( - park_vars.retracted_length and - park_vars.park_operation not in ['unload', 'cancel', 'complete'] - ) %} - {% set should_restore_nextxy = ( - park_vars.next_pos and - restore_xy_pos == 'next' and - park_vars.park_operation == 'toolchange' - ) %} - {% set should_restore_xy = ( - park_vars.saved_pos and - (restore_xy_pos != 'none' or park_vars.park_operation != 'toolchange') - ) %} - - {% if restore %} # Allows override for initial tool load and final unload - G90 # Absolute - {% if park_vars.toolchange_z > 0 and park_vars.saved_pos and 'z' in printer.toolhead.homed_axes %} - G1 Z{park_vars.toolchange_z} F{park_lift_speed} # Ensure at toolchange height for collision avoidance before move - {% endif %} - {% if should_restore_nextxy %} - {% if vars.user_park_move_macro %} - MMU_LOG MSG="Restoring toolhead position to next pos using {vars.user_park_move_macro} RESTORE=1 (x:{nx|round(1)}, y:{ny|round(1)})" - {vars.user_park_move_macro} RESTORE=1 X={nx} Y={ny} F={park_travel_speed} - {% else %} - MMU_LOG MSG="Restoring toolhead position to next pos: (x:{nx|round(1)}, y:{ny|round(1)}, z:{z|round(1)})" - G1 X{nx} Y{ny} F{park_travel_speed} # Restore X,Y to next print position - {% endif %} - {% elif should_restore_xy %} - {% if vars.user_park_move_macro %} - MMU_LOG MSG="Restoring toolhead position to last pos using {vars.user_park_move_macro} RESTORE=1 (x:{x|round(1)}, y:{y|round(1)})" - {vars.user_park_move_macro} RESTORE=1 X={x} Y={y} F={park_travel_speed} - {% else %} - MMU_LOG MSG="Restoring toolhead position to last pos: (x:{x|round(1)}, y:{y|round(1)}, z:{z|round(1)})" - G1 X{x} Y{y} F{park_travel_speed} # Restore X,Y to last (starting) position - {% endif %} - {% elif park_vars.saved_pos %} - MMU_LOG MSG="Restoring toolhead position to: (z:{z|round(1)})" - {% endif %} - {% if park_vars.saved_pos and 'z' in printer.toolhead.homed_axes %} - G1 Z{z} F{park_lift_speed} # Restore original Z height - {% endif %} - {% else %} - MMU_LOG MSG="Skipping restore of toolhead XYZ position" - {% endif %} - - {% if should_unretract %} - _MMU_UNRETRACT - {% endif %} - - _MMU_CLEAR_POSITION - - -########################################################################### -# Helper macro: Retract filament -# -[gcode_macro _MMU_RETRACT] -description: Helper to retract filament -gcode: - {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set retracted_length = park_vars.retracted_length %} - {% set length = params.LENGTH|default(0)|float|abs %} - {% set speed = sequence_vars.retract_speed|int %} - {% set length = [length - retracted_length, 0] | max %} - - {% if printer.extruder.can_extrude %} - {% if length > 0 %} - MMU_LOG MSG="Retracting {length}mm" - M83 ; Extruder relative - G1 E-{length} F{speed|abs * 60} - {% endif %} - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=retracted_length VALUE={length} - {% else %} - MMU_LOG MSG="Extruder is not hot enough to retract" DEBUG=1 - {% endif %} - - -########################################################################### -# Helper macro: Undo retract filament move -# -[gcode_macro _MMU_UNRETRACT] -description: Helper to extruder filament to undo retract -gcode: - {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set length = park_vars.retracted_length %} - {% set speed = sequence_vars.unretract_speed|int %} - - {% if printer.extruder.can_extrude %} - {% if length > 0 %} - MMU_LOG MSG="Un-retracting {length}mm" - M83 ; Extruder relative - G1 E{length} F{speed|abs * 60} - {% endif %} - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=retracted_length VALUE=0 - {% else %} - MMU_LOG MSG="Extruder is not hot enough to un-retract" DEBUG=1 - {% endif %} - - -########################################################################### -# Helper macro: clear previously saved position -# -[gcode_macro _MMU_CLEAR_POSITION] -description: Clear previously recorded toolhead position -gcode: - {% set reset = params.RESET|default(0)|int %} - - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=saved_pos VALUE={False} - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=next_pos VALUE={False} - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=toolchange_z VALUE=-1 - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=retracted_length VALUE=0 - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=park_operation VALUE=\"\" - {% if reset %} - # Reset rising floor used in sequential printing - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=min_lifted_z VALUE=0 - {% endif %} - MMU_LOG MSG="Clearing saved toolhead position{' and rising toolchange floor' if reset else ''}" DEBUG=1 - - -########################################################################### -# Helper macro: option to auto home toolhead -# -[gcode_macro _MMU_AUTO_HOME] -description: Convenience auto homing primarily for testing -gcode: - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set auto_home = vars.auto_home|default(true)|lower == 'true' %} - - {% if auto_home and 'xyz' not in printer.toolhead.homed_axes %} - MMU_LOG MSG="Automatically homing XYZ before parking toolhead" - G28 - {% endif %} - - -########################################################################### -# Helper macro: record maximum toolhead height -# Designed to be called from slicer layer changed logic -# -[gcode_macro MMU_UPDATE_HEIGHT] -description: Record maximum toolhead height for z-hop base (call on layer change for sequential printing) -gcode: - {% set height = params.HEIGHT|default(0)|float %} - {% set park_vars = printer['gcode_macro _MMU_PARK'] %} - {% set max_z = [park_vars.min_lifted_z, printer.gcode_move.gcode_position.z, height]|max %} - - {% if max_z > park_vars.min_lifted_z %} - MMU_LOG MSG="Setting rising toolchange floor to {max_z}" DEBUG=1 - {% endif %} - SET_GCODE_VARIABLE MACRO=_MMU_PARK VARIABLE=min_lifted_z VALUE={max_z} - - -########################################################################### -# This occurs prior to unloading filament on a toolchange -# -[gcode_macro _MMU_PRE_UNLOAD] -description: Optional pre unload routine for filament change -gcode: - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set x, y, z_hop = vars.pre_unload_position|default([-999,-999,0])|map('float') %} - - {% if x != -999 or y != -999 or z_hop > 0 %} - _MMU_PARK FORCE_PARK=1 X={x} Y={y} Z_HOP={z_hop} - {% endif %} - {vars.user_pre_unload_extension|default("")} - - -########################################################################### -# This occurs immediately after the tip forming or cutting procedure -# -[gcode_macro _MMU_POST_FORM_TIP] -description: Optional post tip forming/cutting routing -gcode: - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set x, y, z_hop = vars.post_form_tip_position|default([-999,-999,0])|map('float') %} - - {% if x != -999 or y != -999 or z_hop > 0 %} - _MMU_PARK FORCE_PARK=1 X={x} Y={y} Z_HOP={z_hop} - {% endif %} - {vars.user_post_form_tip_extension|default("")} - - -########################################################################### -# This occurs immediately after unloading filament on a toolchange -# -[gcode_macro _MMU_POST_UNLOAD] -description: Optional post unload routine for filament change -gcode: - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - - # This is a good place to inject logic to, for example, perform tip - # cutting when cutter is located at the MMU, thus prepping the unloaded - # filament for next use (e.g. EREC) - {vars.user_post_unload_extension|default("")} - - -########################################################################### -# This occurs prior to starting the load sequence on a toolchange -# -[gcode_macro _MMU_PRE_LOAD] -description: Optional pre load routine for filament change -gcode: - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set x, y, z_hop = vars.pre_load_position|default([-999,-999,0])|map('float') %} - - {% if x != -999 or y != -999 or z_hop > 0 %} - _MMU_PARK FORCE_PARK=1 X={x} Y={y} Z_HOP={z_hop} - {% endif %} - {vars.user_pre_load_extension|default("")} - - -########################################################################### -# This occurs after loading new filament on a toolchange -# -[gcode_macro _MMU_POST_LOAD] -description: Optional post load routine for filament change -gcode: - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {% set timelapse = vars.timelapse|default(false)|lower == 'true' %} - - {% if timelapse %} - TIMELAPSE_TAKE_FRAME - {% endif %} - - # A good place to implement custom purging logic and/or nozzle cleaning - # prior to returning to print/wipetower (e.g. Blobifier) - {vars.user_post_load_extension|default("")} - - -########################################################################### -# This is called when a MMU error occurs -# -[gcode_macro _MMU_ERROR] -description: Called when an MMU error occurs -gcode: - {% set vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} - {vars.user_mmu_error_extension|default("")} - - -########################################################################### -# ADVANCED ADVANCED ADVANCED ADVANCED ADVANCED ADVANCED -# User modifiable loading and unloading sequences -# -# By default Happy Hare will call internal logic to handle loading and unloading -# sequences. To enable the calling of user defined sequences you must add the -# following to your mmu_parameters.cfg -# -# gcode_load_sequence: 1 # Gcode loading sequence 1=enabled, 0=internal logic (default) -# gcode_unload_sequence: 1 # Gcode unloading sequence, 1=enabled, 0=internal logic (default) -# -# This reference example load sequence mimicks the internal ones exactly. It uses the -# high level "modular" movements that are all controlled by parameters defined in -# mmu_parameters.cfg and automatically keep the internal filament position state up-to-date. -# Switching to these macros should not change behavior and can serve as a starting point for -# your customizations -# -# State Machine: -# If you experiment beyond the basic example shown here you will need to understand -# the possible states for filament position. This is the same state that is exposed -# as the `printer.mmu.filament_pos` printer variable. This internal state must be -# kept up-to-date and will need to be set directly as you progress through your -# custom move sequence. At this time the state machine is non-extensible. -# -# FILAMENT_POS_UNKNOWN = -1 -# L ^ FILAMENT_POS_UNLOADED = 0 -# O | FILAMENT_POS_HOMED_GATE = 1 # If gate sensor fitted -# A | FILAMENT_POS_START_BOWDEN = 2 -# D | FILAMENT_POS_IN_BOWDEN = 3 -# FILAMENT_POS_END_BOWDEN = 4 -# | U FILAMENT_POS_HOMED_ENTRY = 5 # If extruder (entry) sensor fitted -# | N FILAMENT_POS_HOMED_EXTRUDER = 6 -# | L FILAMENT_POS_PAST_EXTRUDER = 7 -# | O FILAMENT_POS_HOMED_TS = 8 # If toolhead sensor fitted -# | A FILAMENT_POS_IN_EXTRUDER = 9 # AKA Filament is past the Toolhead Sensor -# v D FILAMENT_POS_LOADED = 10 # AKA Filament is homed to the nozzle -# -# Final notes: -# 1) You need to respect the context being passed into the macro such as the -# desired 'length' to move because this can be called for test loading -# 2) The unload macro can be called with the filament in any position (states) -# You are required to handle any starting point. The default reference -# serves as a good guide -# -[gcode_macro _MMU_LOAD_SEQUENCE] -description: Called when MMU is asked to load filament -gcode: - {% set filament_pos = params.FILAMENT_POS|float %} - {% set length = params.LENGTH|float %} - {% set full = params.FULL|int %} - {% set home_extruder = params.HOME_EXTRUDER|int %} - {% set skip_extruder = params.SKIP_EXTRUDER|int %} - {% set extruder_only = params.EXTRUDER_ONLY|int %} - - {% if extruder_only %} - _MMU_STEP_LOAD_TOOLHEAD EXTRUDER_ONLY=1 - - {% elif filament_pos >= 7 %} # FILAMENT_POS_PAST_EXTRUDER - {action_raise_error("Can't load - already in extruder!")} - - {% else %} - {% if filament_pos <= 0 %} # FILAMENT_POS_UNLOADED - _MMU_STEP_LOAD_GATE - {% endif %} - - {% if filament_pos < 4 %} # FILAMENT_POS_END_BOWDEN - _MMU_STEP_LOAD_BOWDEN LENGTH={length} - {% endif %} - - {% if filament_pos < 6 and home_extruder %} # FILAMENT_POS_HOMED_EXTRUDER - _MMU_STEP_HOME_EXTRUDER - {% endif %} - - {% if not skip_extruder %} # FILAMENT_POS_PAST_EXTRUDER - _MMU_STEP_LOAD_TOOLHEAD - {% endif %} - - {% endif %} - -[gcode_macro _MMU_UNLOAD_SEQUENCE] -description: Called when MMU is asked to unload filament -gcode: - {% set filament_pos = params.FILAMENT_POS|float %} - {% set length = params.LENGTH|float %} - {% set extruder_only = params.EXTRUDER_ONLY|int %} - {% set park_pos = params.PARK_POS|float %} - - {% if extruder_only %} - {% if filament_pos >= 7 %} # FILAMENT_POS_PAST_EXTRUDER - _MMU_STEP_UNLOAD_TOOLHEAD EXTRUDER_ONLY=1 PARK_POS={park_pos} - {% else %} - {action_raise_error("Can't unload extruder - already unloaded!")} - {% endif %} - - {% elif filament_pos == 0 %} - {action_raise_error("Can't unload - already unloaded!")} - - {% else %} - {% if filament_pos >= 7 %} # FILAMENT_POS_PAST_EXTRUDER - # Exit extruder, fast unload of bowden, then slow unload encoder - _MMU_STEP_UNLOAD_TOOLHEAD PARK_POS={park_pos} - {% endif %} - - {% if filament_pos >= 4 %} # FILAMENT_POS_END_BOWDEN - # Fast unload of bowden, then slow unload encoder - _MMU_STEP_UNLOAD_BOWDEN FULL=1 - _MMU_STEP_UNLOAD_GATE - - {% elif filament_pos >= 2 %} # FILAMENT_POS_START_BOWDEN - # Have to do slow unload because we don't know exactly where in the bowden we are - _MMU_STEP_UNLOAD_GATE FULL=1 - {% endif %} - - {% endif %} - -# -# Some examples of alternative macros follow -# -# 1. This loading example leverages the built-in modules to load filament to the end -# of the bowden tube. Then homes the filament to the toolhead sensor (toolhead) -# using synchronized gear and extruder movement. The state is updated to reflect this -# new position. It then performs a synchronized stepper move of 62mm to advance the -# filament to the nozzle -# -#[gcode_macro _MMU_LOAD_SEQUENCE] -#description: Called when MMU is asked to load filament -#gcode: -# {% set filament_pos = params.FILAMENT_POS|float %} -# {% set length = params.LENGTH|float %} -# {% set skip_extruder = params.SKIP_EXTRUDER|int %} -# {% set extruder_only = params.EXTRUDER_ONLY|int %} -# -# {% if extruder_only %} -# _MMU_STEP_HOMING_MOVE ENDSTOP=toolhead MOVE=50 MOTOR=extruder -# _MMU_STEP_SET_FILAMENT STATE=8 # FILAMENT_POS_HOMED_TS -# _MMU_STEP_MOVE MOVE=62 MOTOR=extruder -# _MMU_STEP_SET_FILAMENT STATE=10 # FILAMENT_POS_LOADED -# {% else %} -# _MMU_STEP_LOAD_GATE -# _MMU_STEP_LOAD_BOWDEN LENGTH={length} -# {% if full and not skip_extruder %} -# _MMU_STEP_HOMING_MOVE ENDSTOP=toolhead MOVE=50 MOTOR=gear+extruder -# _MMU_STEP_SET_FILAMENT STATE=8 # FILAMENT_POS_HOMED_TS -# _MMU_STEP_MOVE MOVE=62 MOTOR=gear+extruder -# _MMU_STEP_SET_FILAMENT STATE=10 # FILAMENT_POS_LOADED -# {% endif %} -# {% endif %} -# -# -# 2. This very streamlined loading example starts off similarly loading to the end of the -# calibrated bowden. It then simply homes to the nozzle (using TMC stallguard on the extruder -# stepper!) with synchronized extruder+gear steppers. This requires the `mmu_ext_touch` -# endstop to be defined for the EXTRUDER stepper (this is possible with Happy Hare extension) -# -#[gcode_macro _MMU_LOAD_SEQUENCE] -#description: Called when MMU is asked to load filament -#gcode: -# {% set length = params.LENGTH|float %} -# {% set full = params.FULL|int %} -# {% set skip_extruder = params.SKIP_EXTRUDER|int %} -# {% set extruder_only = params.EXTRUDER_ONLY|int %} -# -# {% if extruder_only %} -# _MMU_STEP_HOMING_MOVE ENDSTOP=mmu_ext_touch MOVE=100 MOTOR=extruder -# _MMU_STEP_SET_FILAMENT STATE=10 # FILAMENT_POS_LOADED -# {% else %} -# _MMU_STEP_LOAD_GATE -# _MMU_STEP_LOAD_BOWDEN LENGTH={length} -# {% if full and not skip_extruder %} -# _MMU_STEP_HOMING_MOVE ENDSTOP=mmu_ext_touch MOVE=100 MOTOR=extruder+gear -# _MMU_STEP_SET_FILAMENT STATE=10 # FILAMENT_POS_LOADED -# {% endif %} -# {% endif %} - diff --git a/klippy/extras/Happy-Hare/config/base/mmu_software.cfg b/klippy/extras/Happy-Hare/config/base/mmu_software.cfg deleted file mode 100644 index 98ecf345e8da..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_software.cfg +++ /dev/null @@ -1,567 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Miscellaneous supporting macros -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# - - -########################################################################### -# Convenience print start marco that users can call directly from their -# slicer's custom "start g-code" or call from existing start marco -# -# To call from slicer (recommended), add these lines to your custom start -# g-code (before and after the call to your regular print start macro). -# It is recommended to separate the filament purge portion of the start -# sequence until after the initial tool is loaded. -# -# Slicer: Custom Start g-code -# +----------------------------------------------------------+ -# | ; Initialize MMU and save info from gcode file | -# | MMU_START_SETUP INITIAL_TOOL={initial_tool} | -# | REFERENCED_TOOLS=!referenced_tools! | -# | TOOL_COLORS=!colors! | -# | TOOL_TEMPS=!temperatures! | -# | TOOL_MATERIALS=!materials! | -# | FILAMENT_NAMES=!filament_names! | -# | PURGE_VOLUMES=!purge_volumes! | -# | | -# | ; Check MMU is setup for the slicer defined print | -# | MMU_START_CHECK | -# | | -# | ; Bed leveling, heating logic, etc for print start | -# | ; (Nothing that requires filament in extruder) | -# | PRINT_START ; call you existing macro here.. | -# | | -# | ; Load slicer defined initial tool into MMU | -# | MMU_START_LOAD_INITIAL_TOOL | -# | | -# | ; Final purge logic before starting to print | -# | ..optionally call you purge logic start macro.. | -# +----------------------------------------------------------+ -# -# NOTE: The reason that it is recommended to add these 4 or 5 lines to your -# slicer is to keep them as separate gcode macros to enable the print to -# pause in the case of an error. If you bundle everything into a single -# print start macro then the first opportunity to pause will be at the end -# of that, potentially long running, macro! -# -# Alternatively you can pass in the params to your existing print start -# macro and then insert these calls in that macro (but not recommended -# because of pause warning above) -# -# MMU_START_SETUP {rawparams} -# MMU_START_CHECK -# MMU_START_LOAD_INITIAL_TOOL -# -[gcode_macro MMU_START_SETUP] -description: Called when starting print to setup MMU -gcode: - {% set initial_tool = params.INITIAL_TOOL|default(0)|int %} - {% set total_toolchanges = params.TOTAL_TOOLCHANGES|default(0)|int %} - {% set ttg_map = printer.mmu.ttg_map %} - {% set gate_fil_names = printer.mmu.gate_filament_name %} - {% set gate_colors = printer.mmu.gate_color %} - {% set num_gates = ttg_map|length %} - {% set referenced_tools = (params.REFERENCED_TOOLS|default("!referenced_tools!")|string).split(",") - if (params.REFERENCED_TOOLS and params.REFERENCED_TOOLS != "") - else [] %} - {% set tool_colors = (params.TOOL_COLORS|default("")|string).split(",") - if (params.TOOL_COLORS and params.TOOL_COLORS != "!colors!" and params.TOOL_COLORS != "") - else ['000000'] * num_gates %} - {% set tool_temps = (params.TOOL_TEMPS|default("")|string).split(",") - if (params.TOOL_TEMPS and params.TOOL_TEMPS != "!temperatures!" and params.TOOL_TEMPS != "") - else ['0'] * num_gates %} - {% set tool_materials = (params.TOOL_MATERIALS|default("")|string).split(",") - if (params.TOOL_MATERIALS and params.TOOL_MATERIALS != "!materials!" and params.TOOL_MATERIALS != "") - else ['unknown'] * num_gates %} - {% set filament_names = (params.FILAMENT_NAMES|default("")|string).split(",") - if (params.FILAMENT_NAMES and params.FILAMENT_NAMES != "!filament_names!" and params.FILAMENT_NAMES != "") - else [''] * num_gates %} - {% set purge_volumes = (params.PURGE_VOLUMES|default("")|string) - if (params.PURGE_VOLUMES and params.PURGE_VOLUMES != "!purge_volumes!" and params.PURGE_VOLUMES != "") - else "" %} - - {% set vars = printer['gcode_macro _MMU_SOFTWARE_VARS'] %} - {% set home_mmu = vars.home_mmu|lower == 'true' %} - - {% set filament_loaded = printer.mmu.filament_pos == 10 %} - {% set using_bypass = printer.mmu.tool == -2 %} - {% set num_colors = referenced_tools|length %} - - {% if printer.mmu.enabled %} - # Bookend for start of MMU print job. Initializes MMU print state - # Necessary when printing from Octoprint (but harmless if printing from virtual SD card) - MMU_PRINT_START - - # Typically this would be something like a G28 to ensure homing in case of pause - {% if not vars.user_pre_initialize_extension == "" %} - {vars.user_pre_initialize_extension} - {% endif %} - - # Establish number of colors in print and tools being used - {% if referenced_tools == ['!referenced_tools!'] %} - MMU_LOG MSG="Happy Hare gcode pre-processor is probably disabled or not setup correctly" - {% set referenced_tools = [] %} - {% set num_colors = -1 %} - {% elif referenced_tools == [] %} - {% set num_colors = 1 %} - {% endif %} - - # Sanity check the parsed information - {% set num_slicer_tools = tool_colors|length %} - {% if not using_bypass %} - {% if tool_colors|length != tool_temps|length or tool_colors|length != tool_materials|length or tool_colors|length != filament_names|length %} - MMU_LOG MSG="Warning: Slicer defined extruder attributes have different lengths. Possibly an issue with parsing slicer information or missing parameters to MMU_START_SETUP" - MMU_LOG MSG=" TOOL_COLORS={tool_colors}" - MMU_LOG MSG=" TOOL_TEMPS={tool_temps}" - MMU_LOG MSG=" TOOL_MATERIALS={tool_materials}" - MMU_LOG MSG=" FILAMENT_NAMES={filament_names}" - {% endif %} - {% if tool_colors|length != num_gates or tool_temps|length != num_gates or tool_materials|length != num_gates or filament_names|length != num_gates %} - {% if vars.automap_strategy != 'none' %} - MMU_LOG MSG="Warning: Looks like slicer is setup with {num_slicer_tools} extruders but your MMU has {num_gates} gates! Probably using auto-map feature." - {% else %} - MMU_LOG MSG="Warning: Looks like slicer is setup with {num_slicer_tools} extruders but your MMU has {num_gates} gates! These should match but will attempt to continue" - {% endif %} - {% endif %} - {% endif %} - - # Setup slicer tool map - MMU_SLICER_TOOL_MAP RESET=1 PURGE_VOLUMES={purge_volumes} NUM_SLICER_TOOLS={num_slicer_tools} INITIAL_TOOL={initial_tool} TOTAL_TOOLCHANGES={total_toolchanges} - {% if using_bypass %} - MMU_SLICER_TOOL_MAP SKIP_AUTOMAP=1 - {% endif %} - {% for t in range(num_slicer_tools) %} - MMU_SLICER_TOOL_MAP TOOL={t} TEMP={tool_temps[t]} MATERIAL='{tool_materials[t]}' COLOR={tool_colors[t]} NAME='{filament_names[t]}' {"USED=0" if t|string not in referenced_tools and t != initial_tool else ""} QUIET=1 AUTOMAP={vars.automap_strategy} - {% endfor %} - - # Build message in case of error - {% set custom_msg = [] %} - {% set m = [] %} - {% for tool in referenced_tools %} - {% set _ = m.append("T" + tool|string + " (Gate" + ttg_map[tool|int]|string + ")") %} - {% endfor %} - {% set line = "Initial Tool: T%s" % initial_tool %} - {% set _ = m.append(line) %} - {% set _ = custom_msg.append("Print requires tools: %s" % ", ".join(m)) %} - {% set _ = custom_msg.append("Manually ensure that T" + initial_tool|string + " is loaded and all other tools available before resuming print") %} - - # Display map summary - {% if num_colors > 1 %} - MMU_SLICER_TOOL_MAP SPARSE_PURGE_MAP=1 NUM_SLICER_TOOLS={num_slicer_tools} - {% else %} - MMU_SLICER_TOOL_MAP - {% endif %} - - SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=show_abort VALUE={True} # Show abort option during startup - {% if using_bypass and filament_loaded %} - MMU_LOG MSG="MMU Bypass selected and loaded" - {% if num_colors > 1 %} - SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=custom_msg VALUE="{custom_msg}" - MMU_PAUSE MSG="Bypass selected for multi-color print" - {% endif %} - {% else %} - # Preemptively set verbose dialog message in case of additional mmu error during start - SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=custom_msg VALUE="{custom_msg}" - {% if home_mmu %} - {% if not filament_loaded %} - MMU_HOME TOOL={initial_tool} - {% else %} - MMU_LOG MSG="Skipping homing MMU because filament is already loaded" - {% endif %} - {% endif %} - {% endif %} - {% endif %} - SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_setup_run VALUE={True} - - -########################################################################### -# Helper macro to check required gates have filament. This is separated out -# from main setup macro to allow for pausing on previous error first -# -[gcode_macro MMU_START_CHECK] -description: Helper macro. Can be called to perform pre-start checks on MMU based on slicer requirements -gcode: - {% set vars = printer['gcode_macro _MMU_SOFTWARE_VARS'] %} - {% set check_gates = vars.check_gates|lower == 'true' %} - {% set using_bypass = printer.mmu.tool == -2 %} - - {% if printer.mmu.enabled %} - {% set slicer_tool_map = printer.mmu.slicer_tool_map %} - {% set initial_tool = slicer_tool_map.initial_tool %} - {% set tools = slicer_tool_map.referenced_tools %} - {% if not using_bypass %} - # Future: Could do extra checks like filament material type/color checking here - # to ensure what's loaded on MMU matches the slicer expectations - {% if check_gates and tools|length > 0 %} - # Pre-check gates option if multi-color print. Will pause if tools missing - MMU_LOG MSG="Checking all required gates have filament loaded..." - {% if not printer.mmu.is_homed %} - MMU_HOME - {% endif %} - MMU_CHECK_GATE TOOLS={tools|join(",")} - {% endif %} - {% endif %} - {% endif %} - SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_check_run VALUE={True} - - -########################################################################### -# Helper macro to load the initial tool. This is separated out from main -# setup macro to allow for pausing on previous error first -# -[gcode_macro MMU_START_LOAD_INITIAL_TOOL] -description: Helper to load initial tool if not paused -gcode: - {% set vars = printer['gcode_macro _MMU_SOFTWARE_VARS'] %} - {% set load_initial_tool = vars.load_initial_tool|lower == 'true' %} - {% set using_bypass = printer.mmu.tool == -2 %} - {% set filament_loaded = printer.mmu.filament_pos == 10 %} - {% set slicer_tool_map = printer.mmu.slicer_tool_map %} - {% set initial_tool = slicer_tool_map.initial_tool %} - {% set tools = slicer_tool_map.referenced_tools %} - - {% if printer.mmu.enabled %} - {% if not using_bypass and tools|length > 0 %} - {% if load_initial_tool and (initial_tool is not none and initial_tool >= 0) %} - MMU_LOG MSG="Loading initial tool T{initial_tool}..." - MMU_CHANGE_TOOL STANDALONE=1 RESTORE=0 TOOL={initial_tool} - {% endif %} - {% elif not filament_loaded %} - MMU_PAUSE MSG="Load bypass or initial tool before resuming print" - {% else %} - MMU_LOG MSG="Using bypass" - {% endif %} - {% endif %} - - # Important: Clear preemptive error message and remove abort option from pause dialog - SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=custom_msg VALUE='""' - SET_GCODE_VARIABLE MACRO=_MMU_ERROR_DIALOG VARIABLE=show_abort VALUE={False} - SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_load_initial_tool_run VALUE={True} - - -########################################################################### -# Convenience print end marco that users can call directly from their -# slicer's custom "end g-code" or call from existing end marco -# -# To call from slicer, add this to custom end g-code (possibly as one line -# just after the call to your regular print end macro) or call directly from -# without your existing print end macro: -# -# Slicer: Custom End g-code -# +----------------------------------------------------------+ -# | ; Finalize MMU and optionally park and unload filament | -# | MMU_END | -# | | -# | ; Your existing print end macro | -# | PRINT_END | -# +----------------------------------------------------------+ -# -[gcode_macro MMU_END] -description: Called when ending print to finalize MMU -gcode: - {% set unload = params.UNLOAD|default(0)|int %} - {% set vars = printer['gcode_macro _MMU_SOFTWARE_VARS'] %} - {% set unload_tool = vars.unload_tool|lower == 'true' %} - {% set reset_ttg = vars.reset_ttg|lower == 'true' %} - {% set dump_stats = vars.dump_stats|lower == 'true' %} - {% set slicer_tool_map = printer.mmu.slicer_tool_map %} - {% set tools = slicer_tool_map.referenced_tools %} - {% set using_bypass = printer.mmu.tool == -2 %} - - {% if printer.mmu.enabled %} - {% if not vars.user_print_end_extension == "" %} - {vars.user_print_end_extension} - {% endif %} - - {% if unload or unload_tool %} - MMU_LOG MSG="Unloading filament on print end" - MMU_UNLOAD RESTORE=0 - {% endif %} - - {% if reset_ttg %} - MMU_TTG_MAP RESET=1 QUIET=1 - {% endif %} - - {% if dump_stats and not using_bypass and tools|length > 0 %} - MMU_STATS - {% endif %} - - # Bookend for end of MMU print job. Finalizes MMU state - MMU_PRINT_END STATE=complete - {% endif %} - SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_end_run VALUE={True} - - -########################################################################### -# Helper macro that will walk the user through a cold-pull -# -# Assumes the bowden tube is removed from the toolhead and the extruder -# is loaded with about a 300mm piece of filament. The user should have access -# to the filament to assist pull when asked -# -# Params: -# MATERIAL=nylon|pla|abs|petg Starting temp defaults -# HOT_TEMP Initial high temp -# COLD_TEMP Temp to cool too to help release filament -# MIN_EXTRUDE_TEMP Temp to which the extruder will keep nozzle pressurized -# PULL_TEMP Temp to perform the cold pull -# PULL_SPEED Speed in mm/s of extruder movement to help manual pull -# CLEAN_LENGTH Amount of filament to extrude to prime extruder/nozzle -# EXTRUDE_SPEED Speed in mm/s to perform extrude operations -# -[gcode_macro MMU_COLD_PULL] -description: Guide you through the process of cleaning your extruder with a cold pull -gcode: - {% set material = params.MATERIAL|default("pla")|string|upper %} - {% set materials = { - 'NYLON': {'hot_temp': 260, 'cold_temp': 50, 'pull_temp': 120, 'min_extrude_temp': 190}, - 'PLA': {'hot_temp': 250, 'cold_temp': 45, 'pull_temp': 100, 'min_extrude_temp': 160}, - 'ABS': {'hot_temp': 255, 'cold_temp': 50, 'pull_temp': 120, 'min_extrude_temp': 190}, - 'PETG': {'hot_temp': 250, 'cold_temp': 45, 'pull_temp': 100, 'min_extrude_temp': 180} - } %} - {% if material not in materials %} - {action_raise_error("Unknown material. Valid types are: Nylon, ABS, PLA, PETG")} - {% endif %} - - # Allow individual temperature overrides. Coded like this so Mainsail can parse options - {% set hot_temp = params.HOT_TEMP|default('')|int %} - {% set cold_temp = params.COLD_TEMP|default('')|int %} - {% set pull_temp = params.PULL_TEMP|default('')|int %} - {% set min_extrude_temp = params.MIN_EXTRUDE_TEMP|default('')|int %} - {% set hot_temp = (hot_temp if hot_temp > 0 else materials.get(material).hot_temp)|int %} - {% set cold_temp = (cold_temp if cold_temp > 0 else materials.get(material).cold_temp)|int %} - {% set pull_temp = (pull_temp if pull_temp > 0 else materials.get(material).pull_temp)|int %} - {% set min_extrude_temp = (min_extrude_temp if min_extrude_temp > 0 else materials.get(material).min_extrude_temp)|int %} - - {% set pull_speed = params.PULL_SPEED|default(10)|int %} - {% set clean_length = params.CLEAN_LENGTH|default(25)|int %} - {% set extrude_speed = params.EXTRUDE_SPEED|default(1.5)|float %} - - {% set ns = namespace(stuff_points=[], cool_points=[]) %} - - {% for temp in range(hot_temp + 1, cold_temp - 1, -1) %} - {% if temp % 10 == 0 %} - {% if temp > min_extrude_temp %} - {% set ns.stuff_points = ns.stuff_points + [temp] %} - {% elif temp < min_extrude_temp %} - {% set ns.cool_points = ns.cool_points + [temp] %} - {% endif %} - {% endif %} - {% endfor %} - - MMU_LOG MSG='{"Cold Pull based on %s profile (use MATERIAL= to adjust):" % material}' - MMU_LOG MSG='{"pull_temp=%d\u00B0C, hot_temp=%d\u00B0C, min_extruder=%d\u00B0C, cold_temp=%d\u00B0C" % (pull_temp, hot_temp, min_extrude_temp, cold_temp)}' - - MMU_LOG MSG='{"Heating extruder to %d\u00B0C..." % hot_temp}' - SET_HEATER_TEMPERATURE HEATER="extruder" TARGET={hot_temp} - TEMPERATURE_WAIT SENSOR="extruder" MINIMUM={hot_temp - 2} MAXIMUM={hot_temp + 2} - - # Ensure the nozzle id completely full - MMU_LOG MSG="Cleaning nozzle tip with {clean_length}mm of filament" - _MMU_STEP_MOVE MOTOR="extruder" MOVE={clean_length} SPEED={extrude_speed} ALLOW_BYPASS=1 - - # Begin the cooling ramp - MMU_LOG MSG="Allowing extruder to cool..." - SET_HEATER_TEMPERATURE HEATER="extruder" TARGET={cold_temp} - M106 S255 # 100% part fan to cool faster - - # While filament can still extrude keep the nozzle completely full - {% for temp in ns.stuff_points %} - TEMPERATURE_WAIT SENSOR="extruder" MAXIMUM={temp} - MMU_LOG MSG='{"> Stuffing nozzle at %d\u00B0C" % temp}' - _MMU_STEP_MOVE MOTOR="extruder" MOVE=1 SPEED={extrude_speed} ALLOW_BYPASS=1 - {% endfor %} - - # Give some feedback on cooling process - MMU_LOG MSG='{"Waiting for extruder to completely cool to %d\u00B0C..." % cold_temp}' - {% for temp in ns.cool_points %} - TEMPERATURE_WAIT SENSOR="extruder" MAXIMUM={temp} - MMU_LOG MSG='{"> Nozzle at %d\u00B0C" % temp}' - {% endfor %} - TEMPERATURE_WAIT SENSOR="extruder" MAXIMUM={cold_temp} - - # Re-warm - M107 # Part fan off - MMU_LOG MSG='{"Re-warming extruder to %d\u00B0C" % pull_temp}' - SET_HEATER_TEMPERATURE HEATER="extruder" TARGET={pull_temp} - - # The manual cold-pull - TEMPERATURE_WAIT SENSOR="extruder" MINIMUM={pull_temp - 10} - MMU_LOG MSG="Get ready to pull..." - TEMPERATURE_WAIT SENSOR="extruder" MINIMUM={pull_temp} - MMU_LOG MSG=">>>>> PULL NOW <<<<<" - - # Retract 150 mm at moderate speed (user should assist pull too, especially in bypass)) - _MMU_STEP_MOVE MOTOR="extruder" MOVE=-150 SPEED={pull_speed} ALLOW_BYPASS=1 - - MMU_LOG MSG="Cold pull is successful if you can see the shape of the nozzle at the filament end" - MMU_LOG MSG="If not, try again, perhaps with tweaked temperatures" - - # Heater completely off - SET_HEATER_TEMPERATURE HEATER="extruder" - - -########################################################################### -# Helper macros to display dialog in supporting UIs when MMU pauses -# -[gcode_macro _MMU_ERROR_DIALOG] -description: Helper to display pause dialog -variable_custom_msg: '' # List of additional custom message lines to append in dialog -variable_show_abort: False -gcode: - {% set message = params.MSG|string %} - {% set reason = params.REASON|string %} - RESPOND TYPE=command MSG="action:prompt_begin Happy Hare Error Notice" - RESPOND TYPE=command MSG='{"action:prompt_text %s" % message}' - RESPOND TYPE=command MSG='{"action:prompt_text Reason: %s" % reason}' - {% if not custom_msg == "" %} - {% for line in custom_msg %} - RESPOND TYPE=command MSG='{"action:prompt_text %s" % line}' - {% endfor %} - {% else %} - RESPOND TYPE=command MSG="action:prompt_text After fixing, call RESUME to continue printing (MMU_UNLOCK to restore temperature)" - {% endif %} - RESPOND TYPE=command MSG="action:prompt_button_group_start" - {% if show_abort %} - RESPOND TYPE=command MSG="action:prompt_button ABORT|CANCEL_PRINT|error" - {% endif %} - RESPOND TYPE=command MSG="action:prompt_button UNLOCK|MMU_UNLOCK|secondary" - RESPOND TYPE=command MSG="action:prompt_button RESUME|RESUME|warning" - RESPOND TYPE=command MSG="action:prompt_button_group_end" - RESPOND TYPE=command MSG="action:prompt_show" - {% set custom_msg = "" %} - - -########################################################################### -# Helper for Klippain to reset start/end step "run" trackers -# -[gcode_macro _MMU_RUN_MARKERS] -variable_mmu_start_setup_run: False -variable_mmu_start_check_run: False -variable_mmu_start_load_initial_tool_run: False -variable_mmu_end_run: False -gcode: - SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_setup_run VALUE=False - SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_check_run VALUE=False - SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_start_load_initial_tool_run VALUE=False - SET_GCODE_VARIABLE MACRO=_MMU_RUN_MARKERS VARIABLE=mmu_end_run VALUE=False - - -########################################################################### -# Simplified subset of commands just for macro visibility in -# Mainsail/Fluidd UI (until custom HH panel is complete!) -# The __ is a trick because it is not displayed by the UI but allows for -# similar names to the real commands defined by the klipper module -# -[gcode_macro MMU__UNLOAD] -gcode: MMU_UNLOAD - -[gcode_macro MMU__EJECT] -gcode: MMU_EJECT - -[gcode_macro MMU__HOME] -gcode: - {% set tool = params.TOOL|default(0)|int %} - {% set force_unload = params.FORCE_UNLOAD|default(0)|int %} - MMU_HOME TOOL={tool} FORCE_UNLOAD={force_unload} - -[gcode_macro MMU__STATUS] -gcode: MMU_STATUS - -[gcode_macro MMU__MOTORS_OFF] -gcode: MMU_MOTORS_OFF - -[gcode_macro MMU__SERVO] -gcode: - {% set pos = params.POS|default("up")|string %} - MMU_SERVO POS={pos} - -[gcode_macro MMU__SELECT_TOOL] -gcode: - {% set tool = params.TOOL|default(0)|int %} - MMU_SELECT TOOL={tool} - -[gcode_macro MMU__SELECT_BYPASS] -gcode: MMU_SELECT_BYPASS - -[gcode_macro MMU__LOAD_BYPASS] -gcode: MMU_LOAD - -[gcode_macro MMU__RECOVER] -gcode: MMU_RECOVER - -[gcode_macro MMU__PRELOAD] -gcode: - MMU_PRELOAD {rawparams} - -[gcode_macro MMU__CHECK_GATE] -gcode: - {% set gate = params.GATE|default(-1)|int %} - {% set tool = params.GATE|default(-1)|int %} - {% set gates = params.GATE|default('!')|string %} - {% set tools = params.GATE|default('!')|string %} - MMU_CHECK_GATE GATE={gate} TOOL={tool} GATES={gates} TOOLS={tools} - - -########################################################################### -# Macro to query filament pressure sensor (proportional sync sensor) state -# -[gcode_macro MMU_QUERY_PSENSOR] -description: "Show raw and scaled readings for proportional (sync_feedback_analog_*) sensor" -variable_sensor: "filament_proportional" -gcode: - {% set name = params.SENSOR|default(sensor) %} - {% set obj = printer[name] %} - M118 PSENSOR Enabled: {obj.enabled} Value: {obj.value|float|round(3)} Raw Value: {obj.value_raw|float|round(3)} - - -########################################################################### -# Aliases (for backward compatibility) of previously well used commands... -# -[gcode_macro MMU_CHANGE_TOOL_STANDALONE] -description: Convenience macro for inclusion in print_start for initial tool load -gcode: - MMU_CHANGE_TOOL {rawparams} STANDALONE=1 - -[gcode_macro MMU_CHECK_GATES] -description: Alias for updated macro name of MMU_CHECK_GATE -gcode: - MMU_CHECK_GATE ALL=1 - -[gcode_macro MMU_REMAP_TTG] -description: Alias for updated macro name of MMU_TTG_MAP -gcode: - MMU_TTG_MAP {rawparams} - -[gcode_macro MMU_FORM_TIP] -description: Alias for updated macro name of MMU_TEST_FORM_TIP -gcode: - MMU_TEST_FORM_TIP {rawparams} - -# Underscore was removed from these to indicate user can call -[gcode_macro _MMU_PRINT_START] -description: Alias for updated macro name of MMU_PRINT_START -gcode: - MMU_PRINT_START {rawparams} - -[gcode_macro _MMU_PRINT_END] -description: Alias for updated macro name of MMU_PRINT_END -gcode: - MMU_PRINT_END {rawparams} - -[gcode_macro _MMU_UPDATE_HEIGHT] -description: Alias for updated macro name of MMU_UPDATE_HEIGHT -gcode: - MMU_UPDATE_HEIGHT {rawparams} diff --git a/klippy/extras/Happy-Hare/config/base/mmu_state.cfg b/klippy/extras/Happy-Hare/config/base/mmu_state.cfg deleted file mode 100644 index 902192d1137a..000000000000 --- a/klippy/extras/Happy-Hare/config/base/mmu_state.cfg +++ /dev/null @@ -1,124 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Callouts for Happy Hare state changes -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# - - -########################################################################### -# Called when when the MMU action status changes -# -# The `ACTION` parameter will contain the current action string -# (also available in `printer.mmu.action` printer variable). -# Also the previous action is available in `OLD_ACTION`. -# -# See Happy Hare README for full list of action strings, but a quick ref is: -# -# Idle|Loading|Unloading|Loading Ext|Exiting Ext|Heating|Checking|Homing|Selecting -# Forming Tip|Cutting Tip|Cutting Filament|Purging -# -[gcode_macro _MMU_ACTION_CHANGED] -description: Called when an action has changed -gcode: - {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} - {% set action = params.ACTION|string %} - {% set old_action = params.OLD_ACTION|string %} - - {% if not vars.user_action_changed_extension == "" %} - {vars.user_action_changed_extension} {rawparams} - {% endif %} - - -########################################################################### -# Called when the MMU print state changes -# -# The `STATE` parameter will contain the current state string -# (also available in `printer.mmu.print_state` printer variable) -# Also the previous action is available in `OLD_STATE`. -# -# See Happy Hare README for full list of state strings and the state transition -# diagram, but a quick ref is: -# -# initialized|ready|started|printing|complete|cancelled|error|pause_locked|paused|standby -# -[gcode_macro _MMU_PRINT_STATE_CHANGED] -description: Called when print state changes -gcode: - {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} - {% set state = params.STATE|string %} - {% set old_state = params.OLD_STATE|string %} - - {% if not vars.user_print_state_changed_extension == "" %} - {vars.user_print_state_changed_extension} {rawparams} - {% endif %} - - -########################################################################### -# Called when an atomic event occurs. Different from ACTION_CHANGE because -# these are not necessarily part of any important state change but rather -# informational -# -# The `EVENT` parameter will contain the event name. Other parameters -# depend on the event type -# -# See Happy Hare README for full list of event strings, but a quick ref is: -# -# Events: -# "restart" Called when Happy Hare starts / restarts -# Parameters: None -# -# "gate_map_changed" Called when the MMU gate_map (containing information -# about the filament type, color, availability and -# spoolId) is updated -# Parameters: GATE The gate that is updated or -1 if all updated -# -# "filament_gripped" Called when MMU servo (if fitted) grips filament -# Parameters: None -# -# "filament_cut" Called when filament is cut -# Parameters: None -# -[gcode_macro _MMU_EVENT] -description: Called when certain MMU actions occur -gcode: - {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} - {% set servo_down_limit = vars.servo_down_limit|default(-1)|int %} - {% set cutter_blade_limit = vars.cutter_blade_limit|default(-1)|int %} - {% set event = params.EVENT|string %} - - {% if event == "restart" %} - MMU_STATS COUNTER=mmu_restarts INCR=1 - - {% set vendor = printer.configfile.config.mmu_machine.mmu_vendor|string|lower %} - {% set version = printer.configfile.config.mmu_machine.mmu_version|string|lower %} - {% if vendor == "ercf" %} - MMU_STATS COUNTER=servo_down LIMIT={servo_down_limit} WARNING="Inspect servo arm for wear/damage" - MMU_STATS COUNTER=cutter_blade LIMIT={cutter_blade_limit} WARNING="Inspect/replace filament cutting blade" - {% elif vendor == "tradrack" %} - MMU_STATS COUNTER=servo_down LIMIT={servo_down_limit} WARNING="Inspect servo mechanism for wear/damage" - MMU_STATS COUNTER=cutter_blade LIMIT={cutter_blade_limit} WARNING="Inspect/replace filament cutting blade" - {% endif %} - - {% elif event == "gate_map_changed" %} - ; - {% elif event == "filament_gripped" %} - MMU_STATS COUNTER=servo_down INCR=1 - {% elif event == "filament_cut" %} - MMU_STATS COUNTER=cutter_blade INCR=1 - {% endif %} - - {% if not vars.user_mmu_event_extension == "" %} - {vars.user_mmu_event_extension} {rawparams} - {% endif %} - diff --git a/klippy/extras/Happy-Hare/config/mmu_vars.cfg b/klippy/extras/Happy-Hare/config/mmu_vars.cfg deleted file mode 100644 index 0bd436a184ba..000000000000 --- a/klippy/extras/Happy-Hare/config/mmu_vars.cfg +++ /dev/null @@ -1,8 +0,0 @@ -# This is the template file for storing Happy Hare state and calibration variables. It is pointed to -# with the [save_variables] block in 'mmu_macro_vars.cfg' -# -# If you want to use an existing "variables" file, then that is fine but make sure you copy the -# "mmu__revision" line to it because Happy Hare will look for this to validate correct setup -# -[Variables] -mmu__revision = 0 diff --git a/klippy/extras/Happy-Hare/config/optional/client_macros.cfg b/klippy/extras/Happy-Hare/config/optional/client_macros.cfg deleted file mode 100644 index 909a464153b0..000000000000 --- a/klippy/extras/Happy-Hare/config/optional/client_macros.cfg +++ /dev/null @@ -1,132 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# -# Portions integrated from mainsail.cfg -# Copyright (C) 2022 Alex Zellner -# -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Complimentary and functional "client" macros that work with MMU enabled or disabled -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -# These are the recommended PAUSE/RESUME/CANCEL_PRINT macros for use with -# Happy Hare that use the parking logic defined in 'mmu_sequence.cfg' and are -# centrally configured in 'mmu_macro_vars.cfg' -# -# (Technically you can also use your own set but you will likely need to -# modify configuration to avoid double retraction, etc) -# -[gcode_macro PAUSE] -rename_existing: BASE_PAUSE -description: Pause the print and park -gcode: - {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} - - {% if printer.pause_resume.is_paused %} - MMU_LOG MSG="Print is already paused" - {% else %} - _MMU_SAVE_POSITION - BASE_PAUSE - {% if not printer.mmu.enabled %} - _MMU_PARK OPERATION="pause" - {% endif %} - {vars.user_pause_extension|default("")} - {% endif %} - -[gcode_macro RESUME] -rename_existing: BASE_RESUME -description: Resume the print -gcode: - {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} - - {% if not printer.pause_resume.is_paused %} - MMU_LOG MSG="Print is not paused. Resume ignored" - {% else %} - {vars.user_resume_extension|default("")} - {% if not printer.mmu.enabled %} - _MMU_RESTORE_POSITION # This will take the correct "over and down" movement path and unretract - {% endif %} - BASE_RESUME - {% endif %} - -[gcode_macro CANCEL_PRINT] -rename_existing: BASE_CANCEL_PRINT -description: Cancel print -gcode: - {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} - {% set reset_ttg_on_cancel = vars.reset_ttg_on_cancel|default('true')|lower == 'true' %} - {% set unload_tool_on_cancel = vars.unload_tool_on_cancel|default('false')|lower == 'true' %} - - MMU_LOG MSG="Print cancelled!" - {% if not printer.mmu.enabled %} - _MMU_PARK OPERATION="cancel" - {% else %} - {% if unload_tool_on_cancel %} - MMU_LOG MSG="Ejecting filament on print cancel" - MMU_UNLOAD RESTORE=0 - {% endif %} - {% if reset_ttg_on_cancel %} - MMU_TTG_MAP RESET=1 QUIET=1 - {% endif %} - {% endif %} - _MMU_CLEAR_POSITION - TURN_OFF_HEATERS - M107 ; Fan off - SET_PAUSE_NEXT_LAYER ENABLE=0 - SET_PAUSE_AT_LAYER ENABLE=0 LAYER=0 - {vars.user_cancel_extension|default("")} - BASE_CANCEL_PRINT - - -# The following macros are copied from the Mainsail client macros (mainsail.cfg) -# They are integrated here to add the extra functionality into the Happy Hare -# client_macros whilst still retaining centralized and consistent parking logic -# -# Copyright (C) 2022 Alex Zellner - -# Usage: SET_PAUSE_NEXT_LAYER [ENABLE=[0|1]] [MACRO=] -[gcode_macro SET_PAUSE_NEXT_LAYER] -description: Enable a pause if the next layer is reached -gcode: - {% set pause_next_layer = printer['gcode_macro SET_PRINT_STATS_INFO'].pause_next_layer %} - {% set ENABLE = params.ENABLE|default(1)|int != 0 %} - {% set MACRO = params.MACRO|default(pause_next_layer.call, True) %} - SET_GCODE_VARIABLE MACRO=SET_PRINT_STATS_INFO VARIABLE=pause_next_layer VALUE="{{ 'enable': ENABLE, 'call': MACRO }}" - -# Usage: SET_PAUSE_AT_LAYER [ENABLE=[0|1]] [LAYER=] [MACRO=] -[gcode_macro SET_PAUSE_AT_LAYER] -description: Enable/disable a pause if a given layer number is reached -gcode: - {% set pause_at_layer = printer['gcode_macro SET_PRINT_STATS_INFO'].pause_at_layer %} - {% set ENABLE = params.ENABLE|int != 0 if params.ENABLE is defined else params.LAYER is defined %} - {% set LAYER = params.LAYER|default(pause_at_layer.layer)|int %} - {% set MACRO = params.MACRO|default(pause_at_layer.call, True) %} - SET_GCODE_VARIABLE MACRO=SET_PRINT_STATS_INFO VARIABLE=pause_at_layer VALUE="{{ 'enable': ENABLE, 'layer': LAYER, 'call': MACRO }}" - -# Usage: SET_PRINT_STATS_INFO [TOTAL_LAYER=] [CURRENT_LAYER=] -[gcode_macro SET_PRINT_STATS_INFO] -rename_existing: SET_PRINT_STATS_INFO_BASE -description: Overwrite, to get pause_next_layer and pause_at_layer feature -variable_pause_next_layer: { 'enable': False, 'call': "PAUSE" } -variable_pause_at_layer : { 'enable': False, 'layer': 0, 'call': "PAUSE" } -gcode: - {% if pause_next_layer.enable %} - MMU_LOG MSG='{"%s, forced by pause_next_layer" % pause_next_layer.call}' - {pause_next_layer.call} ; execute the given gcode to pause, should be either M600 or PAUSE - SET_PAUSE_NEXT_LAYER ENABLE=0 - {% elif pause_at_layer.enable and params.CURRENT_LAYER is defined and params.CURRENT_LAYER|int == pause_at_layer.layer %} - MMU_LOG MSG='{"%s, forced by pause_at_layer [%d]" % (pause_at_layer.call, pause_at_layer.layer)}' - {pause_at_layer.call} ; execute the given gcode to pause, should be either M600 or PAUSE - SET_PAUSE_AT_LAYER ENABLE=0 - {% endif %} - SET_PRINT_STATS_INFO_BASE {rawparams} diff --git a/klippy/extras/Happy-Hare/config/optional/mmu_menu.cfg b/klippy/extras/Happy-Hare/config/optional/mmu_menu.cfg deleted file mode 100644 index 2c97f54aa569..000000000000 --- a/klippy/extras/Happy-Hare/config/optional/mmu_menu.cfg +++ /dev/null @@ -1,146 +0,0 @@ -######################################################################################################################## -# Happy Hare MMU Software -# Supporting macros -# -# THIS FILE IS READ ONLY -# -# Copyright (C) 2022 moggieuk#6538 (discord) moggieuk@hotmail.com -# This file may be distributed under the terms of the GNU GPLv3 license. -# -# Goal: Happy Hare MMU MENU designed for LCD Mini12864 screen -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# -[menu __main __MMU] -enable: {printer.mmu.enabled} -type: list -name: MMU -index: 6 - -[menu __main __MMU __HOME] -type: command -name: Home MMU -index: 1 -gcode: MMU_HOME - -[menu __main __MMU __SERVO_UP] -type: command -name: Servo up -index: 2 -gcode: MMU_SERVO POS=up - -[menu __main __MMU __SERVO_MOVE] -type: command -name: Servo move -index: 3 -gcode: MMU_SERVO POS=move - -[menu __main __MMU __SERVO_DOWN] -type: command -name: Servo down -index: 4 -gcode: MMU_SERVO POS=down - -[menu __main __MMU __CHANGE_TOOL] -type: input -name: Change Tool: {'%2d' % (menu.input|int)} -input: 0 -input_min: 0 -input_max: 8 -input_step: 1 -index: 5 -gcode: - MMU_CHANGE_TOOL STANDALONE=1 TOOL={menu.input|int} - -[menu __main __MMU __SELECT_TOOL] -type: input -name: Select Tool: {'%2d' % (menu.input|int)} -input: 0 -input_min: 0 -input_max: 8 -input_step: 1 -index: 6 -gcode: - MMU_SELECT TOOL={menu.input|int} - -[menu __main __MMU __PRELOAD_TOOL] -type: input -name: Preload Tool: {'%1d' % (menu.input|int)} -input: 0 -input_min: 0 -input_max: 8 -input_step: 1 -index: 7 -gcode: - MMU_PRELOAD GATE={menu.input|int} - -[menu __main __MMU __EJECT] -type: command -name: Eject -index: 8 -gcode: MMU_EJECT - -[menu __main __MMU __RECOVER] -type: command -name: Recover -index: 9 -gcode: MMU_RECOVER - -[menu __main __MMU __SELECT_BYPASS] -enable: {not printer.idle_timeout.state == "Printing"} -type: command -name: Select bypass -index: 10 -gcode: MMU_SELECT_BYPASS - -[menu __main __MMU __LOAD_BYPASS] -enable: {not printer.idle_timeout.state == "Printing" and printer.mmu.gate == -2} -type: command -name: Load bypass -index: 11 -gcode: MMU_LOAD - -[menu __main __MMU __UNLOAD_BYPASS] -enable: {not printer.idle_timeout.state == "Printing" and printer.mmu.gate == -2} -type: command -name: Unload bypass -index: 13 -gcode: MMU_EJECT - -[menu __main __MMU __clogdetection] -type: input -name: Clog detect: {'%2d' % (menu.input|int)} -input: 0 -input_min: 0 -input_max: 2 -input_step: 1 -index: 14 -gcode: - MMU_TEST_CONFIG enable_clog_detection={menu.input|int} - -[menu __main __MMU __endlessspool] -type: input -name: Endl. spool: {'%2d' % (menu.input|int)} -input: 0 -input_min: 0 -input_max: 1 -input_step: 1 -index: 15 -gcode: - MMU_TEST_CONFIG enable_endless_spool={menu.input|int} - -[menu __main __MMU __STATUS] -type: command -name: Show Status -index: 16 -gcode: MMU_STATUS - -[menu __main __MMU __MOTORS_OFF] -type: command -name: Motors off -index: 17 -gcode: MMU_MOTORS_OFF - diff --git a/klippy/extras/Happy-Hare/extras/.pylintrc b/klippy/extras/Happy-Hare/extras/.pylintrc deleted file mode 100644 index d5841953ca85..000000000000 --- a/klippy/extras/Happy-Hare/extras/.pylintrc +++ /dev/null @@ -1,12 +0,0 @@ -[FORMAT] -max-line-length=400 - -[MESSAGES CONTROL] -disable=attribute-defined-outside-init, consider-using-f-string, too-many-lines, fixme, multiple-imports, invalid-name, multiple-statements, missing-function-docstring, unused-import, too-many-public-methods, too-many-return-statements, too-many-branches, too-many-statements, too-many-nested-blocks, missing-module-docstring, missing-class-docstring, too-many-instance-attributes, raise-missing-from, bare-except, broad-except, no-else-return, too-many-locals, too-many-arguments, no-else-raise, unused-argument, lost-exception, logging-not-lazy, super-with-arguments, too-few-public-methods, unnecessary-lambda-assignment, useless-object-inheritance - -[DESIGN] -# Maximum number of boolean expressions in an if statement -max-bool-expr=6 - -[MASTER] -ignore=mmu_test.py diff --git a/klippy/extras/Happy-Hare/install.sh b/klippy/extras/Happy-Hare/install.sh deleted file mode 100755 index e7e0213fcce6..000000000000 --- a/klippy/extras/Happy-Hare/install.sh +++ /dev/null @@ -1,2919 +0,0 @@ -#!/bin/bash -# Happy Hare MMU Software -# -# Installer / Updater script -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# -# Creality K1 Support -# 2024 hamyy -# 2024 Unsweeticetea -# 2024 Dmitry Kychanov -# -VERSION=3.42 # Important: Keep synced with mmy.py - -F_VERSION=$(echo "$VERSION" | sed 's/\([0-9]\+\)\.\([0-9]\)\([0-9]\)/\1.\2.\3/') -SCRIPT="$(readlink -f "$0")" -SCRIPTFILE="$(basename "$SCRIPT")" -SCRIPTPATH="$(dirname "$SCRIPT")" -SCRIPTNAME="$0" -ARGS=( "$@" ) - -# Provide klipper installation path and settings for different systems - -OS_CREALITY_K1="creality-k1" -OS_FLYOS_FAST="flyos-fast" -OS_TYPE="" -if [ $(uname -m) = "mips" ] && [ -d "/usr/data/creality" ]; then - OS_TYPE="${OS_CREALITY_K1}" - echo "Detected Creality K1 series printer" -elif [ $(sed -n 's/^NAME="\(.*\)"/\1/p' /etc/os-release 2>/dev/null) = "FlyOS-Fast" ]; then - OS_TYPE="${OS_FLYOS_FAST}" - echo "Detected FlyOS-Fast" -fi - -KLIPPER_HOME="${HOME}/klipper" -MOONRAKER_HOME="${HOME}/moonraker" -KLIPPER_CONFIG_HOME="${HOME}/printer_data/config" -OCTOPRINT_KLIPPER_CONFIG_HOME="${HOME}" -KLIPPER_LOGS_HOME="${HOME}/printer_data/logs" -OLD_KLIPPER_CONFIG_HOME="${HOME}/klipper_config" - -if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then - KLIPPER_HOME="/usr/share/klipper" - MOONRAKER_HOME="/usr/data/moonraker/moonraker" - KLIPPER_CONFIG_HOME="/usr/data/printer_data/config" - unset OCTOPRINT_KLIPPER_CONFIG_HOME - unset OLD_KLIPPER_CONFIG_HOME -elif [ "$OS_TYPE" = "$OS_FLYOS_FAST" ]; then - KLIPPER_HOME="/data/klipper" - MOONRAKER_HOME="/data/moonraker" - KLIPPER_CONFIG_HOME="/usr/share/printer_data/config" - unset OCTOPRINT_KLIPPER_CONFIG_HOME - unset OLD_KLIPPER_CONFIG_HOME -fi - -clear -set -e # Exit immediately on error - -declare -A PIN 2>/dev/null || { - echo "Please run this script with bash $0" - exit 1 -} - -# Source pin defs for common MCU's -source ${SCRIPTPATH}/pin_defs - -# These pins will usually be on main mcu for wiring simplification -# -_hw_toolhead_sensor_pin="" -_hw_extruder_sensor_pin="" -_hw_gantry_servo_pin="" -_hw_sync_feedback_tension_pin="" -_hw_sync_feedback_compression_pin="" - -# Screen Colors -OFF='\033[0m' # Text Reset -BLACK='\033[0;30m' # Black -RED='\033[0;31m' # Red -GREEN='\033[0;32m' # Green -YELLOW='\033[0;33m' # Yellow -BLUE='\033[0;34m' # Blue -PURPLE='\033[0;35m' # Purple -CYAN='\033[0;36m' # Cyan -WHITE='\033[0;37m' # White - -B_RED='\033[1;31m' # Bold Red -B_GREEN='\033[1;32m' # Bold Green -B_YELLOW='\033[1;33m' # Bold Yellow -B_CYAN='\033[1;36m' # Bold Cyan -B_WHITE='\033[1;37m' # Bold White - -TITLE="${B_WHITE}" -DETAIL="${BLUE}" -INFO="${CYAN}" -EMPHASIZE="${B_CYAN}" -ERROR="${B_RED}" -WARNING="${B_YELLOW}" -PROMPT="${CYAN}" -DIM="${PURPLE}" -INPUT="${OFF}" -SECTION="----------------\n" - -get_logo() { - caption=$1 - logo=$(cat < /dev/null; then - # On a branch (if using tags we will be detached) - git pull --quiet --force - fi - GIT_VER=$(git describe --tags) - echo -e "${B_GREEN}Now on git version ${GIT_VER}" - echo -e "${B_GREEN}Running the new install script..." - cd - >/dev/null - exec "$SCRIPTNAME" "${ARGS[@]}" - exit 0 # Exit this old instance - fi - GIT_VER=$(git describe --tags) - echo -e "${B_GREEN}Already the latest version: ${GIT_VER}" -} - -function nextfilename { - local name="$1" - if [ -d "${name}" ]; then - printf "%s-%s" ${name} $(date '+%Y%m%d_%H%M%S') - else - printf "%s-%s.%s-old" ${name%.*} $(date '+%Y%m%d_%H%M%S') ${name##*.} - fi -} - -function nextsuffix { - local name="$1" - local -i num=0 - while [ -e "$name.0$num" ]; do - num+=1 - done - printf "%s.0%d" "$name" "$num" -} - -verify_not_root() { - if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then - echo -e "${WARNING}This script is run on a ${OS_TYPE} system, so we want it to be run as root" - return - elif [ "$OS_TYPE" = "$OS_FLYOS_FAST" ]; then - echo -e "${WARNING}This script is run on a ${OS_TYPE} system, so we want it to be run as root" - return - else - if [ "$EUID" -eq 0 ]; then - echo -e "${ERROR}This script must not run as root" - exit -1 - fi - fi -} - -check_klipper() { - if [ "$NOSERVICE" -ne 1 ]; then - if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then - # There is no systemd on MIPS, we can only check the running processes - running_klipper_pid=$(ps -o pid,comm,args | grep [^]]/klipper/klippy/klippy.py | awk '{print $1}') - KLIPPER_PID_FILE=/var/run/klippy.pid - - if [ $(cat $KLIPPER_PID_FILE) = $running_klipper_pid ]; then - echo -e "${DIM}Klipper service found" - else - echo -e "${ERROR}Klipper service not found! Please install Klipper first" - exit -1 - fi - else - if [ "$(systemctl list-units --full -all -t service --no-legend | grep -F "${KLIPPER_SERVICE}")" ]; then - echo -e "${DIM}Klipper ${KLIPPER_SERVICE} systemd service found" - else - echo -e "${ERROR}Klipper ${KLIPPER_SERVICE} systemd service not found! Please install Klipper first" - exit -1 - fi - fi - fi -} - -check_octoprint() { - if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then - OCTOPRINT=0 # Octoprint can not be set up on MIPS - elif [ "$OS_TYPE" = "$OS_FLYOS_FAST" ]; then - OCTOPRINT=0 # Octoprint can not be set up on FlyOS-Fast - elif [ "$NOSERVICE" -ne 1 ]; then - if [ "$(sudo systemctl list-units --full -all -t service --no-legend | grep -F "octoprint.service")" ]; then - echo -e "${DIM}OctoPrint service found" - OCTOPRINT=1 - else - OCTOPRINT=0 - fi - fi -} - -verify_home_dirs() { - if [ ! -d "${KLIPPER_HOME}" ]; then - echo -e "${ERROR}Klipper home directory (${KLIPPER_HOME}) not found. Use '-k ' option to override" - exit -1 - fi - if [ ! -d "${KLIPPER_CONFIG_HOME}" ]; then - if [ ! -d "${OLD_KLIPPER_CONFIG_HOME}" ]; then - if [ ! -f "${OCTOPRINT_KLIPPER_CONFIG_HOME}/${PRINTER_CONFIG}" ]; then - echo -e "${ERROR}Klipper config directory (${KLIPPER_CONFIG_HOME} or ${OLD_KLIPPER_CONFIG_HOME}) not found. Use '-c ' option to override" - exit -1 - fi - KLIPPER_CONFIG_HOME="${OCTOPRINT_KLIPPER_CONFIG_HOME}" - else - KLIPPER_CONFIG_HOME="${OLD_KLIPPER_CONFIG_HOME}" - fi - fi - echo -e "${DIM}Klipper config directory (${KLIPPER_CONFIG_HOME}) found" - - if [ ! -d "${MOONRAKER_HOME}" ]; then - if [ "${OCTOPRINT}" -eq 0 ]; then - echo -e "${ERROR}Moonraker home directory (${MOONRAKER_HOME}) not found. Use '-m ' option to override" - exit -1 - fi - echo -e "${WARNING}Moonraker home directory (${MOONRAKER_HOME}) not found. OctoPrint detected, skipping." - fi -} - -# Silently cleanup any potentially old klippy modules -cleanup_old_klippy_modules() { - if [ -d "${KLIPPER_HOME}/klippy/extras" ]; then - for file in mmu.py mmu_toolhead.py mmu_config_setup.py; do - rm -f "${KLIPPER_HOME}/klippy/extras/${file}" - done - fi -} - -link_mmu_plugins() { - echo -e "${INFO}Linking mmu extensions to Klipper..." - if [ -d "${KLIPPER_HOME}/klippy/extras" ]; then - mkdir -p "${KLIPPER_HOME}/klippy/extras/mmu" - for dir in extras extras/mmu; do - for file in ${SRCDIR}/${dir}/*.py; do - ln -sf "$file" "${KLIPPER_HOME}/klippy/${dir}/$(basename "$file")" - done - done - else - echo -e "${WARNING}Klipper extensions not installed because Klipper 'extras' directory not found!" - fi - - echo -e "${INFO}Linking mmu extension to Moonraker..." - if [ -d "${MOONRAKER_HOME}/moonraker/components" ]; then - for file in `cd ${SRCDIR}/components ; ls *.py`; do - ln -sf "${SRCDIR}/components/${file}" "${MOONRAKER_HOME}/moonraker/components/${file}" - done - else - echo -e "${WARNING}Moonraker extensions not installed because Moonraker 'components' directory not found!" - fi -} - -unlink_mmu_plugins() { - echo -e "${INFO}Unlinking mmu extensions from Klipper..." - if [ -d "${KLIPPER_HOME}/klippy/extras" ]; then - for dir in extras extras/mmu; do - for file in ${SRCDIR}/${dir}/*.py; do - rm -f "${KLIPPER_HOME}/klippy/${dir}/$(basename "$file")" - done - done - rm -rf "${KLIPPER_HOME}/klippy/extras/mmu" - else - echo -e "${WARNING}MMU modules not uninstalled because Klipper 'extras' directory not found!" - fi - - echo -e "${INFO}Unlinking mmu extension from Moonraker..." - if [ -d "${MOONRAKER_HOME}/moonraker/components" ]; then - for file in `cd ${SRCDIR}/components ; ls *.py`; do - rm -f "${MOONRAKER_HOME}/moonraker/components/${file}" - done - else - echo -e "${WARNING}MMU modules not uninstalled because Moonraker 'components' directory not found!" - fi -} - -# Parse file config settings into memory -parse_file() { - file="$1" - prefix_filter="$2" - namespace="$3" - merge="$4" - - if [ ! -f "${file}" ]; then - return - fi - - # Read old config files - while IFS= read -r line - do - # Remove leading spaces, comments and config sections - line="${line#"${line%%[![:space:]]*}"}" - line="${line%%#*}" - line="${line%%[*}" - line="${line%%;*}" - - # Check if line is not empty and contains variable or parameter - if [ ! -z "$line" ] && { [ -z "$prefix_filter" ] || [[ "$line" =~ ^($prefix_filter) ]]; }; then - # Split the line into parameter and value - IFS=":=" read -r parameter value <<< "$line" - - # Remove leading and trailing whitespace - parameter=$(echo "$parameter" | xargs) - # Need to be more careful with value because it can be quoted - value=$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - - # If parameter is one of interest and it has a value remember it - if echo "$parameter" | grep -E -q "${prefix_filter}"; then - if [ "${value}" != "" ]; then - combined="${namespace}${parameter}" - if [ ! -z "${!combined+x}" ]; then - if [ "${merge}" == "merge" ]; then - continue # Use existing value - elif [ "${merge}" == "checkdup" ]; then - echo -e "${ERROR}${parameter} defined multiple times!" - fi - fi - # Set/overwrite value in memory - if echo "$value" | grep -q '^{ .*}$'; then - # Special case drying_data dict format. This is fragile, can't wait for v4 to launch! - eval "${combined}=\"${value}\"" - elif echo "$value" | grep -q '^{.*}$'; then - eval "${combined}=\$${value}" - elif [ "${value%"${value#?}"}" = "'" ]; then - eval "${combined}=\'${value}\'" - else - eval "${combined}='${value}'" - fi - fi - fi - fi - done < "${file}" -} - -# Copy config file substituting in memory values from past config or initial interview -update_copy_file() { - src="$1" - dest="$2" - prefix_filter="$3" - namespace="$4" - - # Read the file line by line - while IFS="" read -r line || [ -n "$line" ] - do - if echo "$line" | grep -E -q '^[[:space:]]*#'; then - # Just copy simple comments - echo "$line" - elif [ ! -z "$line" ] && { [ -z "$prefix_filter" ] || [[ "$line" =~ ^($prefix_filter) ]]; }; then - # Line of interest - # Split the line into the part before # and the part after # - parameterAndValueAndSpace=$(echo "$line" | sed 's/^[[:space:]]*//' | sed 's/;/# /' | cut -d'#' -f1) - - comment="" - if echo "$line" | grep -q "#"; then - commentChar="#" - comment=$(echo "$line" | sed 's/[^#]*#//') - elif echo "$line" | grep -q ";"; then - commentChar=";" - comment=$(echo "$line" | sed 's/[^;]*;//') - fi - space=`printf "%s" "$parameterAndValueAndSpace" | sed 's/.*[^[:space:]]\(.*\)$/\1/'` - - if echo "$parameterAndValueAndSpace" | grep -E -q "${prefix_filter}"; then - # If parameter and value exist, substitute the value with the in memory variable of the same name - if echo "$parameterAndValueAndSpace" | grep -E -q '^\['; then - echo "$line" - elif [ -n "$parameterAndValueAndSpace" ]; then - parameter=$(echo "$parameterAndValueAndSpace" | cut -d':' -f1) - value=$(echo "$parameterAndValueAndSpace" | cut -d':' -f2) - if [ -n "${namespace}${parameter}" ]; then - # If 'parameter' is set and not empty, evaluate its value - new_value=$(eval echo "\$${namespace}${parameter}") - if [ -n "${namespace}" ]; then - # Namespaced, use once - eval unset ${namespace}${parameter} - fi - elif [ -n "${parameter}" ]; then - # Try non-namespaced name, multi-use - new_value=$(eval echo "\$${parameter}") - else - # If 'parameter' is unset or empty leave as token - new_value="{$parameter}" - fi - if [ -z "$new_value" ]; then - new_value="''" - fi - if [ -n "$comment" ]; then - echo "${parameter}: ${new_value}${space}${commentChar}${comment}" - else - echo "${parameter}: ${new_value}" - fi - else - echo "$line" - fi - else - echo "$line" - fi - else - # Just copy simple comments - echo "$line" - fi - done < "$src" >"$dest" -} - -# Get MMU type info first -read_previous_mmu_type() { - HAS_SELECTOR="yes" - dest_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" - if [ -f "${dest_cfg}" ]; then - if ! grep -q "^\[stepper_mmu_selector.*\]" "${dest_cfg}"; then - HAS_SELECTOR="no" - fi - fi - HAS_SERVO="yes" - dest_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" - if [ -f "${dest_cfg}" ]; then - if ! grep -q "^\[mmu_servo selector_servo\]" "${dest_cfg}" && ! grep -q "^\[mmu_servo mmu_servo\]" "${dest_cfg}"; then - HAS_SERVO="no" - fi - fi - HAS_ENCODER="yes" - dest_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" - if [ -f "${dest_cfg}" ]; then - if ! grep -q "^\[mmu_encoder mmu_encoder\]" "${dest_cfg}"; then - HAS_ENCODER="no" - fi - fi - HAS_ESPOOLER="yes" - dest_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" - if [ -f "${dest_cfg}" ]; then - if ! grep -q "^\[mmu_espooler mmu_espooler\]" "${dest_cfg}"; then - HAS_ESPOOLER="no" - fi - fi - - # Figure out the selector type based on h/w presence - if [ "$HAS_SELECTOR" == "no" -a "$HAS_SERVO" == "no" ]; then - _hw_selector_type='VirtualSelector' - elif [ "$HAS_SELECTOR" == "no" -a "$HAS_SERVO" == "yes" ]; then - _hw_selector_type='ServoSelector' - elif [ "$HAS_SELECTOR" == "yes" -a "$HAS_SERVO" == "no" ]; then - _hw_selector_type='RotarySelector' - else - _hw_selector_type='LinearSelector' - fi - echo -e "${INFO}HAS_SELECTOR=${HAS_SELECTOR}" - echo -e "${INFO}HAS_SERVO=${HAS_SERVO}" - echo -e "${INFO}HAS_ENCODER=${HAS_ENCODER}" - echo -e "${INFO}HAS_ESPOOLER=${HAS_ESPOOLER}" - echo -e "${INFO}Determined you have a ${_hw_selector_type} or similar" -} - -# Set default parameters from the distribution (reference) config files -read_default_config() { - if [ "$1" == "merge" ]; then - echo -e "${INFO}Merging default configuration parameters..." - merge="merge" - else - echo -e "${INFO}Reading default configuration parameters..." - merge="checkdup" - fi - - if [ "$HAS_SELECTOR" == "no" -a "$HAS_SERVO" == "no" ]; then - parse_file "${SRCDIR}/config/base/mmu_parameters.cfg.vs" "" "_param_" "$merge" - elif [ "$HAS_SELECTOR" == "no" -a "$HAS_SERVO" == "yes" ]; then - parse_file "${SRCDIR}/config/base/mmu_parameters.cfg.ss" "" "_param_" "$merge" - elif [ "$HAS_SELECTOR" == "yes" -a "$HAS_SERVO" == "no" ]; then - parse_file "${SRCDIR}/config/base/mmu_parameters.cfg.rs" "" "_param_" "$merge" - else - # All other selector types - parse_file "${SRCDIR}/config/base/mmu_parameters.cfg" "" "_param_" "$merge" - fi - parse_file "${SRCDIR}/config/base/mmu_macro_vars.cfg" "variable_|filename" "" "$merge" - for file in `cd ${SRCDIR}/config/addons ; ls *.cfg | grep -v "_hw" | grep -v "my_"`; do - parse_file "${SRCDIR}/config/addons/${file}" "variable_" "" "$merge" - done -} - -# Pull parameters from previous installation -read_previous_config() { - - # Get a few vital bits of information stored in mmu_hardware.cfg if available - cfg="mmu_hardware.cfg" - dest_cfg=${KLIPPER_CONFIG_HOME}/mmu/base/${cfg} - if [ -f "${dest_cfg}" ]; then - num_gates=$(sed -n 's/^num_gates[[:space:]]*[:=][[:space:]]*\([0-9,[:space:]]*\)\([[:space:]]*#.*\)\{0,1\}$/\1/p' "${dest_cfg}") - num_gates="${num_gates// /}" - IFS=', ' read -r -a gates_array <<< "$num_gates" - _hw_num_gates=0 - for gate in "${gates_array[@]}"; do - ((_hw_num_gates += gate)) - done - fi - - cfg="mmu_parameters.cfg" - dest_cfg=${KLIPPER_CONFIG_HOME}/mmu/base/${cfg} - if [ -f "${dest_cfg}" -a "$_hw_num_gates" == "" ]; then - _hw_num_gates=$(sed -n 's/^mmu_num_gates[[:space:]]*[:=][[:space:]]*\([0-9]\{1,\}\)[[:space:]]*.*$/\1/p' "${dest_cfg}") - fi - - if [ ! -f "${dest_cfg}" ]; then - echo -e "${WARNING}No previous ${cfg} found. Will install default" - else - echo -e "${INFO}Reading ${cfg} configuration from previous installation..." - parse_file "${dest_cfg}" "" "_param_" - fi - - for cfg in mmu_macro_vars.cfg; do - dest_cfg=${KLIPPER_CONFIG_HOME}/mmu/base/${cfg} - if [ ! -f "${dest_cfg}" ]; then - echo -e "${WARNING}No previous ${cfg} found. Will install default" - else - echo -e "${INFO}Reading ${cfg} configuration from previous installation..." - if [ "${cfg}" == "mmu_macro_vars.cfg" ]; then - parse_file "${dest_cfg}" "variable_|filename" - else - parse_file "${dest_cfg}" "variable_" - fi - fi - done - - # TODO namespace config in third-party addons separately - if [ -d "${KLIPPER_CONFIG_HOME}/mmu/addons" ]; then - for cfg in `cd ${KLIPPER_CONFIG_HOME}/mmu/addons ; ls *.cfg | grep -v "_hw"`; do - dest_cfg=${KLIPPER_CONFIG_HOME}/mmu/addons/${cfg} - if [ ! -f "${dest_cfg}" ]; then - echo -e "${WARNING}No previous ${cfg} found. Will install default" - else - echo -e "${INFO}Reading ${cfg} configuration from previous installation..." - parse_file "${dest_cfg}" "variable_" - fi - done - fi - - # Upgrade / map / force old parameters... - # v2.7.1 - if [ ! "${variable_pin_park_x_dist}" == "" ]; then - variable_pin_park_dist="${variable_pin_park_x_dist}" - fi - if [ ! "${variable_pin_loc_x_compressed}" == "" ]; then - variable_pin_loc_compressed="${variable_pin_loc_x_compressed}" - fi - if [ ! "${variable_park_xy}" == "" ]; then - variable_park_toolchange="${variable_park_xy}, ${_param_z_hop_height_toolchange:-0}, 0, 2" - variable_park_error="${variable_park_xy}, ${_param_z_hop_height_error:-0}, 0, 2" - fi - if [ ! "${variable_lift_speed}" == "" ]; then - variable_park_lift_speed="${variable_lift_speed}" - fi - - if [ "${variable_enable_park}" == "False" ]; then - variable_enable_park_printing="'pause,cancel'" - if [ "${variable_enable_park_runout}" == "True" ]; then - variable_enable_park_printing="'toolchange,load,unload,runout,pause,cancel'" - fi - elif [ "${variable_enable_park_printing}" == "" ]; then - variable_enable_park_printing="'toolchange,load,unload,pause,cancel'" - fi - - if [ "${variable_enable_park_standalone}" == "False" ]; then - variable_enable_park_standalone="'pause,cancel'" - elif [ "${variable_enable_park_standalone}" == "" ]; then - variable_enable_park_standalone="'toolchange,load,unload,pause,cancel'" - fi - - # v2.7.2 - if [ "${_param_toolhead_residual_filament}" == "0" -a ! "${_param_toolhead_ooze_reduction}" == "0" ]; then - _param_toolhead_residual_filament=$_param_toolhead_ooze_reduction - _param_toolhead_ooze_reduction=0 - fi - - # v2.7.3 - Blobifer update - Oct 13th 20204 - if [ ! "${variable_iteration_z_raise}" == "" ]; then - echo -e "${INFO}Setting Blobifier variable_z_raise and variable_purge_length_maximum from previous settings" - variable_z_raise=$(awk -v iter_z_raise="$variable_iteration_z_raise" -v max_iter="$variable_max_iterations_per_blob" -v z_change="$variable_iteration_z_change" 'BEGIN { - triangular_value = (max_iter - 1) * max_iter / 2; - print iter_z_raise * max_iter - triangular_value * z_change; - }') - variable_purge_length_maximum=$(awk -v max_len="$variable_max_iteration_length" -v max_iter="$variable_max_iterations_per_blob" 'BEGIN { print max_len * max_iter }') - fi - - # v3.0.0 - if [ "${_param_auto_calibrate_gates}" != "" ]; then - _param_autotune_rotation_distance=${_param_auto_calibrate_gates} - fi - if [ "${_param_auto_calibrate_bowden}" != "" ]; then - _param_autotune_bowden_length=${_param_auto_calibrate_bowden} - fi - if [ "${_param_endless_spool_final_eject}" != "" ]; then - _param_gate_final_eject_distance=${_param_endless_spool_final_eject} - fi - if [ "${variable_eject_tool}" != "" ]; then - variable_unload_tool=${variable_eject_tool} - fi - if [ "${variable_eject_tool_on_cancel}" != "" ]; then - variable_unload_tool_on_cancel=${variable_eject_tool_on_cancel} - fi - - # v3.0.2 - if [ "${_param_homing_extruder}" != "" ]; then - _hw_homing_extruder=${_param_homing_extruder} - fi - - # v3.1.0 - if [ "${variable_pin_loc_compressed}" != "" ]; then - echo -e "${INFO}Upgrading variable_pin_loc_compressed --> variable_pin_loc_compressed_xy" - pin_loc_x=$(echo ${variable_pin_loc_xy} | cut -d ',' -f1) - pin_loc_y=$(echo ${variable_pin_loc_xy} | cut -d ',' -f2) - if expr "${variable_cutting_axis}" : '.*x.*' >/dev/null; then - variable_pin_loc_compressed_xy="${variable_pin_loc_compressed}, ${pin_loc_y}" - else - variable_pin_loc_compressed_xy="${pin_loc_x}, ${variable_pin_loc_compressed}" - fi - fi - - # v3.2.0 - # eSpooler move from macro to mmu_parameters - if [ "${variable_max_step_speed}" != "" ]; then - _param_espooler_max_stepper_speed=${variable_max_step_speed} - fi - if [ "${variable_min_distance}" != "" ]; then - _param_espooler_min_distance=${variable_min_distance} - fi - if [ "${variable_step_speed_exponent}" != "" ]; then - _param_espooler_speed_exponent=${variable_step_speed_exponent} - fi - # Change -1 to -999 for no park move (allows for negative movement in user macros) - if ! check_for_999 \ - "${variable_park_toolchange}" \ - "${variable_park_runout}" \ - "${variable_park_pause}" \ - "${variable_park_cancel}" \ - "${variable_park_complete}" \ - "${variable_pre_unload_position}" \ - "${variable_post_form_tip_position}" \ - "${variable_pre_load_position}" - then - variable_park_toolchange=$(convert_neg_one "${variable_park_toolchange}") - variable_park_runout=$(convert_neg_one "${variable_park_runout}") - variable_park_pause=$(convert_neg_one "${variable_park_pause}") - variable_park_cancel=$(convert_neg_one "${variable_park_cancel}") - variable_park_complete=$(convert_neg_one "${variable_park_complete}") - variable_pre_unload_position=$(convert_neg_one "${variable_pre_unload_position}") - variable_post_form_tip_position=$(convert_neg_one "${variable_post_form_tip_position}") - variable_pre_load_position=$(convert_neg_one "${variable_pre_load_position}") - fi - - # v3.2.0 - if [ "${_param_sync_feedback_enable}" != "" ]; then - _param_sync_feedback_enabled=${_param_sync_feedback_enable} - fi - - # v3.4.0 - led config moved to v4 format (from macro to python module) - # - #if [ "${variable_led_enable}" != "" ]; then - # _hw_led_enable=$(convert_boolean_string_to_int "${variable_led_enable}") - #fi - - # v3.4.2 - not upgraded because new values will correct user adjustments - # sync_multiplier_high: 1.05 - # sync_multiplier_low: 0.95 - # >> sync_feedback_speed_multiplier: 5 - # >> sync_feedback_extrude_threshold: 5 - # v3.4.2 - name rationalization - # selector_touch_enable >> selector_touch_enabled - # enable_clog_detection >> flowguard_encoder_mode - # enable_endless_spool >> endless_spool_enabled - if [ "${_param_selector_touch_enable}" != "" ]; then - _param_selector_touch_enabled=${_param_selector_touch_enable} - fi - if [ "${_param_enable_clog_detection}" != "" ]; then - _param_flowguard_encoder_mode=${_param_enable_clog_detection} - fi - if [ "${_param_enable_endless_spool}" != "" ]; then - _param_endless_spool_enabled=${_param_enable_endless_spool} - fi -} - -check_for_999() { - for var in "$@"; do - if echo "${var}" | grep -q "\-999"; then - return 0 - fi - done - return 1 -} - -convert_neg_one() { - echo "$1" | awk -F',' ' - { - if ($1 ~ /^ *-1 *$/) $1 = " -999"; - if ($2 ~ /^ *-1 *$/) $2 = " -999"; - OFS="," - for (i=1; i<=NF; i++) { - printf "%s%s", $i, (i/dev/null; then - echo "True" - elif [ "$1" -eq 0 ] 2>/dev/null; then - echo "False" - else - echo "$1" - fi -} - -convert_boolean_string_to_int() { - if [ "$1" == "True" ] 2>/dev/null; then - echo 1 - elif [ "$1" == "False" ] 2>/dev/null; then - echo 0 - else - echo "$1" - fi -} - -# I'd prefer not to attempt to upgrade mmu_hardware.cfg but these will ease pain -# and are relatively safe -upgrade_mmu_hardware() { - echo -e "${INFO}Checking for need to upgrade mmu_hardware.cfg..." - - hardware_cfg="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" - - # v3.0.0: Upgrade mmu_servo to mmu_selector_servo - found_mmu_servo=$(grep -E -c "^\[mmu_servo mmu_servo\]" ${hardware_cfg} || true) - if [ "${found_mmu_servo}" -eq 1 ]; then - sed "s/\[mmu_servo mmu_servo\]/\[mmu_servo selector_servo\]/g" "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" ${hardware_cfg} - echo -e "${INFO}Updated [mmu_servo mmu_servo] in mmu_hardware.cfg..." - fi - - found_mmu_machine=$(grep -E -c "^\[mmu_machine\]" ${hardware_cfg} || true) - - # v3.0.0: Remove num_gates in led section - found_num_gates=$(grep -E -c "^(#?num_gates)" ${hardware_cfg} || true) - if [ "${found_num_gates}" -gt 0 -a "${found_mmu_machine}" -eq 0 ]; then - sed "/^\(#\?num_gates\)/d" "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" ${hardware_cfg} - echo -e "${INFO}Removed 'num_gates' from [mmu_leds] section in mmu_hardware.cfg..." - fi - - # v3.0.0: Add minimal [mmu_machine] section as first section - if [ "${found_mmu_machine}" -eq 0 ]; then - - # Note params will be comming from mmu_parameters - new_section=$(cat < "$temp_file" - awk ' - BEGIN { found = 0 } - /^[[:space:]]*$/ && !found { - print - while ((getline line < "'"$temp_file"'") > 0) print line - close("'"$temp_file"'") - found = 1 - next - } - { print } - ' "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" "${hardware_cfg}" - rm "$temp_file" - - echo -e "${INFO}Added new [mmu_machine] section to mmu_hardware.cfg..." - fi - - # v3.0.2: LED rework - led_strip_value=$(sed -n 's/^led_strip: \(.*\)/\1/p' "$hardware_cfg") - if [ ! "$led_strip_value" == "" ]; then - sed -e '/^led_strip:/d' \ - -e "s|^\(#*\)\(exit_range:\) \(.*\)|\1exit_leds: ${led_strip_value} (\3)|" \ - -e "s|^\(#*\)\(entry_range:\) \(.*\)|\1entry_leds: ${led_strip_value} (\3)|" \ - -e "s|^\(#*\)\(status_index:\) \(.*\)|\1status_leds: ${led_strip_value} (\3)|" \ - "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" "${hardware_cfg}" - - echo -e "${INFO}Updated [mmu_leds] section in mmu_hardware.cfg..." - fi - - # v3.2.0: Add new [mmu_espooler] section as first section - found_mmu_espooler=$(grep -E -c "^#?\[mmu_espooler" ${hardware_cfg} || true) - found_stepper_mmu_gear_1=$(grep -E -c "^\[stepper_mmu_gear_1\]" ${hardware_cfg} || true) - if [ "${found_mmu_espooler}" -eq 0 -a "${found_stepper_mmu_gear_1}" -eq 1 ]; then - - # Note params will be coming from mmu_parameters - new_section=$(cat <> "${hardware_cfg}" - echo -e "${INFO}Added new [mmu_machine] section to mmu_hardware.cfg..." - fi - - # v3.4.0: Update [mmu_leds] section for v4 python impl - found_old_mmu_leds=$(grep -E -c "^\[mmu_leds\]" ${hardware_cfg} || true) - if [ "${found_old_mmu_leds}" -eq 1 ]; then - - sed "s/\[mmu_leds\]/\[mmu_leds unit0\]/g" "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" ${hardware_cfg} - new_section=$(cat < "$temp_file" - awk ' - BEGIN { found = 0 } - /^frame_rate/ && !found { - print - while ((getline line < "'"$temp_file"'") > 0) print line - close("'"$temp_file"'") - found = 1 - next - } - { print } - ' "${hardware_cfg}" > "${hardware_cfg}.tmp" && mv "${hardware_cfg}.tmp" "${hardware_cfg}" - rm "$temp_file" - echo -e "${INFO}Upgraded [mmu_leds] section in mmu_hardware.cfg with new settings..." - fi -} - -copy_config_files() { - mmu_dir="${KLIPPER_CONFIG_HOME}/mmu" - next_mmu_dir="$(nextfilename "${mmu_dir}")" - - echo -e "${INFO}Copying configuration files into ${mmu_dir} directory..." - if [ ! -d "${mmu_dir}" ]; then - mkdir ${mmu_dir} - mkdir ${mmu_dir}/base - mkdir ${mmu_dir}/optional - mkdir ${mmu_dir}/addons - else - echo -e "${DETAIL}Config directory ${mmu_dir} already exists" - echo -e "${DETAIL}Backing up old config files to ${next_mmu_dir}" - mkdir ${next_mmu_dir} - (cd "${mmu_dir}"; cp -r * "${next_mmu_dir}") - - # Ensure all new directories exist - mkdir -p ${mmu_dir}/base - mkdir -p ${mmu_dir}/optional - mkdir -p ${mmu_dir}/addons - fi - - # Now substitute tokens using given brd_type and "questionaire" starting values - : ${_hw_chain_count:=$(expr $_hw_num_gates \* 2 + 2)} - num_leds_minus1=$(expr $_hw_chain_count - 1) - num_gates_plus1=$(expr $_hw_num_gates + 1) - num_gates_mult2=$(expr $_hw_num_gates + $_hw_num_gates) - - # Comment some LEDs unless set by questionaire - vars=("_hw_entry_leds" "_hw_status_leds" "_hw_logo_leds") - for var in "${vars[@]}"; do - if [ -z "${!var+set}" ]; then - declare "_comment${var}=true" - fi - done - - # But still given suggested values even if commented - : ${_hw_exit_leds="neopixel:mmu_leds (1-${_hw_num_gates})"} - : ${_hw_entry_leds="neopixel:mmu_leds (${num_gates_plus1}-${num_gates_mult2})"} - : ${_hw_status_leds="neopixel:mmu_leds (${num_leds_minus1})"} - : ${_hw_logo_leds="neopixel:mmu_leds (${_hw_chain_count})"} - - # Find all variables that start with _hw_, substitute values and comment is necessary - for var in $(compgen -v | grep '^_hw_'); do - value=${!var} - pattern="{${var#_hw_}}" - comment="_comment${var}" - if [ "${!comment}" == "true" ]; then - sed_expr="${sed_expr}/${pattern}/ { s|^|#|; s|${pattern}|${value}|g; }; " - else - sed_expr="${sed_expr}s|${pattern}|${value}|g; " - fi - done - - # Find all variables in the form of PIN[$_hw_brd_type,*] - if [ "$_hw_selector_type" == "VirtualSelector" ]; then - # Type-B MMU has alternative pin allocation - key_match="B,$_hw_brd_type" - else - key_match="$_hw_brd_type" - fi - for key in "${!PIN[@]}"; do - if [[ $key == "$key_match"* ]]; then - value="${PIN[$key]}" - pin_var=$(echo "$key" | sed "s/^$key_match,//") - pattern="{${pin_var}}" - sed_expr="${sed_expr}s|${pattern}|${value}|g; " - fi - done - - for file in `cd ${SRCDIR}/config/base ; ls *.cfg`; do - src=${SRCDIR}/config/base/${file} - dest=${mmu_dir}/base/${file} - next_dest=${next_mmu_dir}/base/${file} - - if [ -f "${dest}" ]; then - if [ "${file}" == "mmu_hardware.cfg" -a "${INSTALL}" -eq 0 ] || [ "${file}" == "mmu.cfg" -a "${INSTALL}" -eq 0 ]; then - echo -e "${WARNING}Skipping copy of hardware config file ${file} because already exists" - continue - else - if [ "${file}" == "mmu_parameters.cfg" ] || [ "${file}" == "mmu_macro_vars.cfg" ]; then - echo -e "${INFO}Upgrading configuration file ${file}" - else - echo -e "${INFO}Installing configuration file ${file}" - fi - mv ${dest} ${next_dest} # Backup old config file - fi - fi - - # Hardware files: Special token substitution ----------------------------------------- - if [ "${file}" == "mmu.cfg" -o "${file}" == "mmu_hardware.cfg" ]; then - - # Kludge to support complete h/w configurations for dedicated MMUs - if [ "${_hw_mmu_vendor}" == "KMS" -o "${_hw_mmu_vendor}" == "VVD" ]; then - if [ "${_hw_mmu_vendor}" == "KMS" ]; then - cp "${src}.kms" ${dest} - else - cp "${src}.vvd" ${dest} - fi - - # Do all the token substitution - cat ${dest} | sed -e "$sed_expr" "${dest}" > "${dest}.tmp" > ${dest}.tmp && mv ${dest}.tmp ${dest} - - # Skip the rest because config was preconfigured - continue - else - cp ${src} ${dest} - fi - - # Correct shared uart_address for EASY-BRD - if [ "${_hw_brd_type}" == "EASY-BRD" ]; then - # Share uart_pin to avoid duplicate alias problem - cat ${dest} | sed -e "\ - s/^uart_pin: mmu:MMU_SEL_UART/uart_pin: mmu:MMU_GEAR_UART/; \ - " > ${dest}.tmp && mv ${dest}.tmp ${dest} - elif [ "${_hw_brd_type}" == "SKR_PICO_1" ]; then - # Share uart_pin to avoid duplicate alias problem - cat ${dest} | sed -e "\ - s/^uart_pin: mmu:MMU_SEL_UART/uart_pin: mmu:MMU_GEAR_UART/; \ - " > ${dest}.tmp && mv ${dest}.tmp ${dest} - else - # Remove uart_address lines - cat ${dest} | sed -e "\ - /^uart_address:/ d; \ - " > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - - if [ "${SETUP_SELECTOR_TOUCH}" == "yes" ]; then - cat ${dest} | sed -e "\ - s/^#\(diag_pin: \^mmu:MMU_SEL_DIAG\)/\1/; \ - s/^#\(driver_SGTHRS: 75\)/\1/; \ - s/^#\(extra_endstop_pins: tmc2209_stepper_mmu_selector:virtual_endstop\)/\1/; \ - s/^#\(extra_endstop_names: mmu_sel_touch\)/\1/; \ - s/^uart_address:/${uart_comment}uart_address:/; \ - " > ${dest}.tmp && mv ${dest}.tmp ${dest} - - elif [ "${SETUP_SELECTOR_STALLGUARD_HOMING}" == "yes" ]; then - cat ${dest} | sed -e "\ - s/^#\(diag_pin: \^mmu:MMU_SEL_DIAG\)/\1/; \ - s/^#\(driver_SGTHRS: 75\)/\1/; \ - s/^endstop_pin: ^mmu:MMU_SEL_ENDSTOP.*$/endstop_pin: tmc2209_stepper_mmu_selector:virtual_endstop/; \ - s/^#\(homing_retract_dist\)/\1/; \ - " > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - - # Do all the token substitution - cat ${dest} | sed -e "$sed_expr" "${dest}" > "${dest}.tmp" > ${dest}.tmp && mv ${dest}.tmp ${dest} - - # Handle LED option - Comment out if disabled (section is last, go comment to end of file) - if [ "${file}" == "mmu_hardware.cfg" -a "$SETUP_LED" == "no" ]; then - sed '/^\[\(neopixel mmu_leds\|mmu_leds\)\]/,${ /^[^#]/ s/^/#/ }' "${dest}" > "${dest}.tmp" && mv "${dest}.tmp" "${dest}" - fi - - # Handle Encoder option - Comment out if not fitted so can easily be added later - if [ "${file}" == "mmu_hardware.cfg" -a "$HAS_ENCODER" == "no" ]; then - sed "/^\[mmu_encoder mmu_encoder\]/,+6 {/^[^#]/ s/^/#/}" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} - #sed "/^# ENCODER/,+24 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - - # Handle Espooler option - Comment out if not fitted so can easily be added later - if [ "${file}" == "mmu_hardware.cfg" -a "$HAS_ESPOOLER" == "no" ]; then - sed "/^\[mmu_espooler mmu_espooler\]/,+27 {/^[^#]/ s/^/#/}" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} - #sed "/^# ESPOOLER/,+41 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - - # Handle Selector options - Delete if not required (sections are 8 and 38 lines respectively) - if [ "${file}" == "mmu_hardware.cfg" ]; then - if [ "$HAS_SELECTOR" == "no" ]; then - sed "/^# SELECTOR STEPPER/,+37 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - - if [ "$HAS_SERVO" == "no" ]; then - sed "/^# SELECTOR SERVO/,+7 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - - if [ "$_hw_selector_type" == "VirtualSelector" ]; then - # Expand out the additional filament drive for each gate - additional_gear_section=$(sed -n "/^# ADDITIONAL FILAMENT DRIVE/,+10 p" ${dest} | sed "1,3d") - awk '{ print } /^# ADDITIONAL FILAMENT DRIVE/ { for (i=1; i<=11; i++) { getline; print }; exit }' ${dest} > ${dest}.tmp - for (( i=2; i<=$(expr $_hw_num_gates - 1); i++ )) - do - echo "$(echo "${additional_gear_section}" | sed "s/_1/_$i/g")" >> ${dest}.tmp - echo >> ${dest}.tmp - done - awk '/^# ADDITIONAL FILAMENT DRIVE/ {flag=1; count=0} flag && count++ >= 12 {print}' ${dest} >> ${dest}.tmp && mv ${dest}.tmp ${dest} - if [ "${_hw_brd_type}" == "SKR_PICO_1" ]; then - # Remove duplicate uart_pin's and add proper uart_addresses - cat ${dest} | sed -e "s/^uart_pin: mmu:MMU_GEAR_UART_1/uart_pin: mmu:MMU_GEAR_UART\nuart_address: 2/" > ${dest}.tmp && mv ${dest}.tmp ${dest} - cat ${dest} | sed -e "s/^uart_pin: mmu:MMU_GEAR_UART_2/uart_pin: mmu:MMU_GEAR_UART\nuart_address: 1/" > ${dest}.tmp && mv ${dest}.tmp ${dest} - cat ${dest} | sed -e "s/^uart_pin: mmu:MMU_GEAR_UART_3/uart_pin: mmu:MMU_GEAR_UART\nuart_address: 3/" > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - else - # Delete additional gear drivers template section - sed "/^# ADDITIONAL FILAMENT DRIVE/,+10 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - fi - - # Configuration parameters ----------------------------------------------------------- - elif [ "${file}" == "mmu_parameters.cfg" ]; then - if [ "$_hw_selector_type" == "VirtualSelector" ] ; then - # Use truncated VirtualSelector parameter file (no selector, no servo) - update_copy_file "${src}.vs" "$dest" "" "_param_" - elif [ "$_hw_selector_type" == "ServoSelector" ] ; then - # Use truncated ServoSelector parameter file (no selector, with servo) - update_copy_file "${src}.ss" "$dest" "" "_param_" - elif [ "$_hw_selector_type" == "RotarySelector" ] ; then - # Use truncated RotarySelector parameter file (with selector, no servo) - update_copy_file "${src}.rs" "$dest" "" "_param_" - else - update_copy_file "$src" "$dest" "" "_param_" - if [ "$HAS_SERVO" == "no" ]; then - # Remove selector servo section - sed "/^# Servo configuration/,+27 d" ${dest} > ${dest}.tmp && mv ${dest}.tmp ${dest} - fi - fi - - # Ensure that supplemental user added params are retained. These are those that are - # by default set internally in Happy Hare based on vendor and version settings but - # can be overridden. This set also includes a couple of hidden test parameters. - echo "" >> $dest - echo "# SUPPLEMENTAL USER CONFIG retained after upgrade --------------------------------------------------------------------" >> $dest - echo "#" >> $dest - supplemental_params="cad_gate0_pos cad_gate_width cad_bypass_offset cad_last_gate_offset cad_block_width cad_bypass_block_width cad_bypass_block_delta cad_selector_tolerance gate_material gate_color gate_spool_id gate_status gate_filament_name gate_temperature gate_speed_override endless_spool_groups tool_to_gate_map" - hidden_params="test_random_failures test_random_failures test_disable_encoder test_force_in_print serious suppress_kalico_warning" - for var in $(set | grep '^_param_' | cut -d'=' -f1 | sort); do - param=${var#_param_} - for item in ${supplemental_params} ${hidden_params}; do - if [ "$item" = "$param" ]; then - value=$(eval echo "\$${var}") - echo "${param}: ${value}" - eval unset ${var} - fi - done - done >> $dest - - # If any params are still left warn the user because they will be lost (should have been upgraded) - for var in $(set | grep '^_param_' | cut -d= -f1); do - param=${var#_param_} - value=$(eval echo \$$var) - echo "Parameter: '$param: $value' is not required or deprecated and has been removed" - done - - # Variables macro --------------------------------------------------------------------- - elif [ "${file}" == "mmu_macro_vars.cfg" ]; then - tx_macros="" - if [ "$_hw_num_gates" == "" -o "$_hw_num_gates" == "{num_gates}" ]; then - _hw_num_gates=12 - fi - for (( i=0; i<=$(expr $_hw_num_gates - 1); i++ )) - do - tx_macros+="[gcode_macro T${i}]\n" - tx_macros+="gcode: MMU_CHANGE_TOOL TOOL=${i}\n" - done - - if [ "${INSTALL}" -eq 1 ]; then - cat ${src} | sed -e "\ - s%{tx_macros}%${tx_macros}%g; \ - " > ${dest} - else - cat ${src} | sed -e "\ - s%{tx_macros}%${tx_macros}%g; \ - " > ${dest}.tmp - update_copy_file "${dest}.tmp" "${dest}" "variable_|filename" && rm ${dest}.tmp - fi - - # Everything else is read-only symlink ------------------------------------------------ - else - ln -sf ${src} ${dest} - fi - done - - # Optional config are read-only symlinks -------------------------------------------------- - for file in `cd ${SRCDIR}/config/optional ; ls *.cfg`; do - src=${SRCDIR}/config/optional/${file} - dest=${mmu_dir}/optional/${file} - ln -sf ${src} ${dest} - done - - # Don't stomp on existing persisted state ------------------------------------------------ - src=${SRCDIR}/config/mmu_vars.cfg - dest=${mmu_dir}/mmu_vars.cfg - if [ -f "${dest}" ]; then - echo -e "${WARNING}Skipping copy of mmu_vars.cfg file because already exists" - else - cp ${src} ${dest} - fi - - # Addon config files are always copied (and updated) so they can be edited ---------------- - # Skipping files with 'my_' prefix for development - for file in `cd ${SRCDIR}/config/addons ; ls *.cfg | grep -v "my_"`; do - src=${SRCDIR}/config/addons/${file} - dest=${mmu_dir}/addons/${file} - if [ -f "${dest}" ]; then - if ! echo "$file" | grep -E -q ".*_hw\.cfg.*"; then - echo -e "${INFO}Upgrading configuration file ${file}" - update_copy_file ${src} ${dest} "variable_" - else - echo -e "${WARNING}Skipping copy of ${file} file because already exists" - fi - else - echo -e "${INFO}Installing configuration file ${file}" - cp ${src} ${dest} - fi - done -} - -remove_old_config_files() { - mmu_dir="${KLIPPER_CONFIG_HOME}/mmu" - if [ -f "${mmu_dir}/addons/dc_espooler.cfg" ]; then - echo -e "${WARNING}Removing legacy dc_spooler macros - configuration now in mmu_hardware.cfg" - rm -f "${mmu_dir}/addons/dc_espooler.cfg" - rm -f "${mmu_dir}/addons/dc_espooler_hw.cfg" - fi -} - - -uninstall_config_files() { - if [ -d "${KLIPPER_CONFIG_HOME}/mmu" ]; then - echo -e "${INFO}Removing MMU configuration files from ${KLIPPER_CONFIG_HOME}" - mv "${KLIPPER_CONFIG_HOME}/mmu" /tmp/mmu.uninstalled - fi -} - -install_printer_includes() { - # Link in all includes if not already present - dest=${KLIPPER_CONFIG_HOME}/${PRINTER_CONFIG} - if test -f $dest; then - - klippain_included=$(grep -c "\[include config/hardware/mmu.cfg\]" ${dest} || true) - if [ "${klippain_included}" -eq 1 ]; then - echo -e "${WARNING}This looks like a Klippain config installation - skipping automatic config install. Please add config includes by hand" - else - next_dest="$(nextfilename "$dest")" - echo -e "${INFO}Copying original ${PRINTER_CONFIG} file to ${next_dest}" - cp ${dest} ${next_dest} - if [ ${ADDONS_EREC} -eq 1 ]; then - i='\[include mmu/addons/mmu_erec_cutter.cfg\]' - already_included=$(grep -c "${i}" ${dest} || true) - if [ "${already_included}" -eq 0 ]; then - sed -i "1i ${i}" ${dest} - fi - fi - if [ ${ADDONS_BLOBIFIER} -eq 1 ]; then - i='\[include mmu/addons/blobifier.cfg\]' - already_included=$(grep -c "${i}" ${dest} || true) - if [ "${already_included}" -eq 0 ]; then - sed -i "1i ${i}" ${dest} - fi - fi - if [ ${ADDONS_EJECT_BUTTONS} -eq 1 ]; then - i='\[include mmu/addons/mmu_eject_buttons.cfg\]' - already_included=$(grep -c "${i}" ${dest} || true) - if [ "${already_included}" -eq 0 ]; then - sed -i "1i ${i}" ${dest} - fi - fi - if [ ${MENU_12864} -eq 1 ]; then - i='\[include mmu/optional/mmu_menu.cfg\]' - already_included=$(grep -c "${i}" ${dest} || true) - if [ "${already_included}" -eq 0 ]; then - sed -i "1i ${i}" ${dest} - fi - fi - if [ ${CLIENT_MACROS} -eq 1 ]; then - i='\[include mmu/optional/client_macros.cfg\]' - already_included=$(grep -c "${i}" ${dest} || true) - if [ "${already_included}" -eq 0 ]; then - sed -i "1i ${i}" ${dest} - fi - fi - for i in '\[include mmu/base/\*.cfg\]' ; do - already_included=$(grep -c "${i}" ${dest} || true) - if [ "${already_included}" -eq 0 ]; then - sed -i "1i ${i}" ${dest} - fi - done - fi - else - echo -e "${WARNING}File ${PRINTER_CONFIG} file not found! Cannot include MMU configuration files" - fi -} - -uninstall_printer_includes() { - echo -e "${INFO}Cleaning MMU references from ${PRINTER_CONFIG}" - dest=${KLIPPER_CONFIG_HOME}/${PRINTER_CONFIG} - if test -f $dest; then - next_dest="$(nextfilename "$dest")" - echo -e "${INFO}Copying original ${PRINTER_CONFIG} file to ${next_dest} before cleaning" - cp ${dest} ${next_dest} - cat "${dest}" | sed -e " \ - /\[include mmu\/*.cfg\]/ d; \ - " > "${dest}.tmp" && mv "${dest}.tmp" "${dest}" - fi -} - -install_update_manager() { - echo -e "${INFO}Adding update manager to moonraker.conf" - file="${KLIPPER_CONFIG_HOME}/moonraker.conf" - if [ -f "${file}" ]; then - restart=0 - - update_section=$(grep -c '\[update_manager happy-hare\]' ${file} || true) - if [ "${update_section}" -eq 0 ] && [ "$OS_TYPE" != "$OS_FLYOS_FAST" ]; then - echo "" >> "${file}" - while read -r line; do - echo -e "${line}" >> "${file}" - done < "${SRCDIR}/moonraker_update.txt" - echo "" >> "${file}" - # The path for Happy-Hare on MIPS is /usr/data/Happy-Hare - if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then - sed -i 's|path: ~/Happy-Hare|path: /usr/data/Happy-Hare|' "${file}" - echo -e "${INFO}Update Happy-Hare path for MIPS architecture." - fi - restart=1 - else - echo -e "${WARNING}[update_manager happy-hare] already exists in moonraker.conf - skipping install" - fi - - # Quick "catch-up" update for new mmu_service - enable_preprocessor="True" - update_section=$(grep -c '\[mmu_server\]' ${file} || true) - if [ "${update_section}" -eq 0 ]; then - echo "" >> "${file}" - echo "[mmu_server]" >> "${file}" - echo "enable_file_preprocessor: ${enable_preprocessor}" >> "${file}" - echo "" >> "${file}" - restart=1 - else - echo -e "${WARNING}[mmu_server] already exists in moonraker.conf - skipping install" - fi - - # Quick "catch-up" update for new toolchange_next_pos pre-processing - update_section=$(grep -c 'enable_toolchange_next_pos' ${file} || true) - if [ "${update_section}" -eq 0 ]; then - awk '/^enable_file_preprocessor/ {print $0 "\nenable_toolchange_next_pos: True\n"; next} {print}' ${file} > ${file}.tmp && mv ${file}.tmp ${file} - restart=1 - echo -e "${WARNING}Added new 'enable_toolchange_next_pos' to moonraker.conf" - fi - - if [ "$restart" -eq 1 ]; then - restart_moonraker - fi - else - echo -e "${WARNING}moonraker.conf not found!" - fi -} - -uninstall_update_manager() { - echo -e "${INFO}Removing update manager from moonraker.conf" - file="${KLIPPER_CONFIG_HOME}/moonraker.conf" - if [ -f "${file}" ]; then - restart=0 - - update_section=$(grep -c '\[update_manager happy-hare\]' ${file} || true) - if [ "${update_section}" -eq 0 ]; then - echo -e "${INFO}[update_manager happy-hare] not found in moonraker.conf - skipping removal" - else - cat "${file}" | sed -e " \ - /\[update_manager happy-hare\]/,+6 d; \ - " > "${file}.new" && mv "${file}.new" "${file}" - restart=1 - fi - - update_section=$(grep -c '\[mmu_server\]' ${file} || true) - if [ "${update_section}" -eq 0 ]; then - echo -e "${INFO}[mmu_server] not found in moonraker.conf - skipping removal" - else - cat "${file}" | sed -e " \ - /\[mmu_server\]/,+1 d; \ - /enable_file_preprocessor/ d; \ - /enable_toolchange_next_pos/ d; \ - /update_spoolman_location/ d; \ - " > "${file}.new" && mv "${file}.new" "${file}" - restart=1 - fi - - if [ "$restart" -eq 1 ]; then - restart_moonraker - fi - else - echo -e "${WARNING}moonraker.conf not found!" - fi -} - -restart_klipper() { - if [ "$NOSERVICE" -ne 1 ]; then - echo -e "${INFO}Restarting Klipper..." - - if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then - set +e - /etc/init.d/*klipper_service restart - set -e - else - sudo systemctl restart ${KLIPPER_SERVICE} - fi - else - echo -e "${WARNING}Klipper restart suppressed - Please restart ${KLIPPER_SERVICE} by hand" - fi -} - -restart_moonraker() { - if [ "$NOSERVICE" -ne 1 ]; then - echo -e "${INFO}Restarting Moonraker..." - - if [ "$OS_TYPE" = "$OS_CREALITY_K1" ]; then - set +e - /etc/init.d/*moonraker_service restart - set -e - else - sudo systemctl restart moonraker - fi - else - echo -e "${WARNING}Moonraker restart suppressed - Please restart by hand" - fi -} - -prompt_yn() { - while true; do - read -n1 -p "$@ (y/n)? " yn - case "${yn}" in - Y|y) - echo -n "y" - break - ;; - N|n) - echo -n "n" - break - ;; - *) - ;; - esac - done -} - -prompt_123() { - prompt=$1 - max=$2 - while true; do - if [ -z "${max}" ]; then - read -ep "${prompt}? " number - elif [[ "${max}" -lt 10 ]]; then - read -ep "${prompt} (1-${max})? " -n1 number - else - read -ep "${prompt} (1-${max})? " number - fi - if ! [[ "$number" =~ ^-?[0-9]+$ ]] ; then - echo -e "Invalid value." >&2 - continue - fi - if [ "$number" -lt 1 ]; then - echo -e "Value must be greater than 0." >&2 - continue - fi - if [ -n "$max" ] && [ "$number" -gt "$max" ]; then - echo -e "Value must be less than $((max+1))." >&2 - continue - fi - echo ${number} - break - done -} - -prompt_option() { - local var_name="$1" - local query="$2" - shift 2 - local i=0 - for val in "$@"; do - i=$((i+1)) - echo "$i) $val" - done - REPLY=$(prompt_123 "$query" "$#") - declare -g $var_name="${!REPLY}" -} - -option() { - local var_name="$1" - local desc="$2" - declare -g $var_name="${desc}" - OPTIONS+=("$desc") -} - -questionaire() { - - # Establish baseline hardware config placeholders to ensure all tokens are expanded - _hw_color_order="GRBW" - _hw_has_bypass=0 - - HAS_ESPOOLER=no - - echo - echo -e "${INFO}Let me see if I can get you started with initial configuration" - echo -e "You will still have some manual editing to perform but I will explain that later" - echo -e "(Note that all this script does is set a lot of the time consuming parameters in the config" - echo - echo -e "${PROMPT}${SECTION}What type of MMU are you running?${INPUT}" - OPTIONS=() - option ERCF11 'Enraged Rabbit Carrot Feeder v1.1' - option ERCF20 'ERCF v2.0' - option ERCF30 'ERCF v3.0' - option TRADRACK 'Tradrack v1.0' - option ANGRY_BEAVER 'Angry Beaver v1.0' - option BOX_TURTLE 'Box Turtle v1.0' - option NIGHT_OWL 'Night Owl v1.0' - #option HTLF 'Happy Turtle Lettuce Feeder' - option _3MS '3MS (Modular Multi Material System) v1.0' - option _3D_CHAMELEON '3D Chameleon' - option PICO_MMU 'PicoMMU' - option QUATTRO_BOX 'QuattroBox v1.0' - option QUATTRO_BOX11 'QuattroBox v1.1' - option MMX 'MMX' - option VVD 'BigTreeTech ViViD (BETA)' - option KMS 'KMS' - option OTHER 'Other / Custom (or just want starter config files)' - prompt_option opt 'MMU Type' "${OPTIONS[@]}" - case $opt in - "$ERCF11") - HAS_ENCODER=yes - HAS_SELECTOR=yes - HAS_SERVO=yes - _hw_mmu_vendor="ERCF" - _hw_mmu_version="1.1" - _hw_selector_type=LinearSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=0 - _hw_gear_gear_ratio="80:20" - _hw_gear_run_current=0.5 - _hw_gear_hold_current=0.1 - _hw_sel_gear_ratio="1:1" - _hw_sel_run_current=0.4 - _hw_sel_hold_current=0.2 - _hw_encoder_resolution=0.7059 - _param_extruder_homing_endstop="collision" - _param_gate_homing_endstop="encoder" - _param_gate_parking_distance=23 - _param_servo_buzz_gear_on_down=3 - _param_servo_duration=0.4 - _param_servo_always_active=0 - _param_servo_buzz_gear_on_down=1 - - echo - echo -e "${PROMPT}Some popular upgrade options for ERCF v1.1 can automatically be setup. Let me ask you about them...${INPUT}" - yn=$(prompt_yn "Are you using the 'Springy' sprung servo selector cart") - echo - case $yn in - y) - _hw_mmu_version+="s" - ;; - esac - yn=$(prompt_yn "Are you using the improved 'Binky' encoder") - echo - case $yn in - y) - _hw_mmu_version+="b" - ;; - esac - yn=$(prompt_yn "Are you using the wider 'Triple-Decky' filament blocks") - echo - case $yn in - y) - _hw_mmu_version+="t" - ;; - esac - ;; - - "$ERCF20") - HAS_ENCODER=yes - HAS_SELECTOR=yes - HAS_SERVO=yes - _hw_mmu_vendor="ERCF" - _hw_mmu_version="2.0" - _hw_selector_type=LinearSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=0 - _hw_gear_gear_ratio="80:20" - _hw_gear_run_current=0.5 - _hw_gear_hold_current=0.1 - _hw_sel_gear_ratio="1:1" - _hw_sel_run_current=0.4 - _hw_sel_hold_current=0.2 - _hw_encoder_resolution=1.0 - _param_extruder_homing_endstop="collision" - _param_gate_homing_endstop="encoder" - _param_gate_parking_distance=13 # ThumperBlocks is 11 - _param_servo_buzz_gear_on_down=3 - _param_servo_duration=0.4 - _param_servo_always_active=0 - _param_servo_buzz_gear_on_down=1 - ;; - - "$ERCF30") - HAS_ENCODER=yes - HAS_SELECTOR=yes - HAS_SERVO=yes - _hw_mmu_vendor="ERCF" - _hw_mmu_version="2.5" - _hw_selector_type=LinearSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=0 - _hw_gear_gear_ratio="1:1" - _hw_gear_run_current=0.8 - _hw_gear_hold_current=0.2 - _hw_sel_gear_ratio="1:1" - _hw_sel_run_current=0.7 - _hw_sel_hold_current=0.2 - _hw_encoder_resolution=1.0 - _param_extruder_homing_endstop="collision" - _param_gate_homing_endstop="encoder" - _param_gate_parking_distance=16 - _param_servo_buzz_gear_on_down=3 - _param_servo_duration=0.4 - _param_servo_always_active=0 - _param_servo_buzz_gear_on_down=1 - ;; - - "$TRADRACK") - HAS_ENCODER=no - HAS_SELECTOR=yes - HAS_SERVO=yes - _hw_mmu_vendor="Tradrack" - _hw_mmu_version="1.0" - _hw_selector_type=LinearSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=0 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=0 - _hw_gear_gear_ratio="50:17" - _hw_gear_run_current=1.27 - _hw_gear_hold_current=0.2 - _hw_sel_gear_ratio="1:1" - _hw_sel_run_current=0.63 - _hw_sel_hold_current=0.2 - _param_extruder_homing_endstop="none" - _param_gate_homing_endstop="mmu_gate" - _param_gate_parking_distance=17.5 - _param_servo_buzz_gear_on_down=0 - _param_servo_always_active=1 - - echo -e "${PROMPT}Some popular upgrade options for Tradrack v1.0 can automatically be setup. Let me ask you about them...${INPUT}" - yn=$(prompt_yn "Are you using the 'Binky' encoder modification") - echo - case $yn in - y) - HAS_ENCODER=yes - _hw_mmu_version+="e" - _param_extruder_homing_endstop="collision" - _param_gate_homing_endstop="encoder" - _param_gate_parking_distance=48.0 - _param_gate_endstop_to_encoder=31.0 - ;; - esac - ;; - - "$ANGRY_BEAVER") - HAS_ENCODER=no - HAS_SELECTOR=no - HAS_SERVO=no - _hw_mmu_vendor="AngryBeaver" - _hw_mmu_version="1.0" - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=0 - _hw_filament_always_gripped=1 - _hw_gear_gear_ratio="1:1" - _hw_gear_run_current=0.7 - _hw_gear_hold_current=0.1 - - _param_extruder_homing_endstop="extruder" - _param_gate_homing_endstop="extruder" - _param_gate_homing_max=500 - _param_gate_preload_homing_max=500 - _param_gate_parking_distance=50 - _param_gear_homing_speed=80 - _param_has_filament_buffer=0 - ;; - - "$BOX_TURTLE") - HAS_ENCODER=no - HAS_SELECTOR=no - HAS_SERVO=no - HAS_ESPOOLER=yes - _hw_mmu_vendor="BoxTurtle" - _hw_mmu_version="1.0" - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=1 - _hw_gear_gear_ratio="50:10" - _hw_gear_run_current=0.7 - _hw_gear_hold_current=0.1 - - _param_extruder_homing_endstop="none" - _param_gate_homing_endstop="mmu_gate" - _param_gate_homing_max=300 - _param_gate_preload_homing_max=200 - _param_gate_parking_distance=100 - _param_gate_final_eject_distance=100 - _param_has_filament_buffer=0 - - _param_autocal_bowden_length=1 - _param_autotune_bowden_length=0 - _param_skip_cal_rotation_distance=0 - _param_autotune_rotation_distance=1 - _param_skip_cal_encoder=0 - _param_autotune_encoder=0 - - _param_sync_feedback_enabled=1 - _param_sync_feedback_buffer_range=8 - _param_sync_feedback_buffer_maxrange=12 - ;; - - "$NIGHT_OWL") - HAS_ENCODER=no - HAS_SELECTOR=no - HAS_SERVO=no - _hw_mmu_vendor="NightOwl" - _hw_mmu_version="1.0" - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=1 - _hw_gear_gear_ratio="50:10" - _hw_gear_run_current=0.7 - _hw_gear_hold_current=0.1 - - _param_extruder_homing_endstop="none" - _param_gate_homing_endstop="mmu_gear" - _param_gate_homing_max=100 - _param_gate_homing_buffer=50 - _param_gate_parking_distance=0 - _param_gate_final_eject_distance=100 - _param_has_filament_buffer=0 - ;; - - "$HTLF") - # Comming soon (j/k)... - HAS_ENCODER=no - HAS_SELECTOR=no - HAS_SERVO=yes - ;; - - "$_3MS") - HAS_ENCODER=no - HAS_SELECTOR=no - HAS_SERVO=no - HELP_URL="https://github.com/moggieuk/Happy-Hare/wiki/Quick-Start-3MS" - HELP_URL_B="https://3dcoded.github.io/3MS/instructions/" - - _hw_mmu_vendor="3MS" - _hw_mmu_version="1.0" - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=0 - _hw_filament_always_gripped=1 - _hw_gear_gear_ratio="1:1" - _hw_gear_run_current=0.7 - _hw_gear_hold_current=0.1 - - _param_extruder_homing_endstop="extruder" - _param_gate_homing_endstop="extruder" - _param_gate_homing_max=500 - _param_gate_parking_distance=250 - _param_gear_homing_speed=80 - ;; - - "$_3D_CHAMELEON") - HAS_ENCODER=no - HAS_SELECTOR=yes - HAS_SERVO=no - SETUP_SELECTOR_TOUCH=no - - _hw_mmu_vendor="3DChameleon" - _hw_mmu_version="1.0" - _hw_selector_type=RotarySelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=0 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=0 - _hw_gear_gear_ratio="1:1" - _hw_gear_run_current=0.7 - _hw_gear_hold_current=0.1 - _hw_sel_gear_ratio="1:1" - _hw_sel_run_current=0.63 - _hw_sel_hold_current=0.2 - - _param_extruder_homing_endstop="none" - _param_gate_homing_endstop="mmu_gate" - _param_gate_homing_max=500 - _param_gate_parking_distance=250 - _param_gear_homing_speed=80 - ;; - - "$PICO_MMU") - HAS_ENCODER=no - HAS_SELECTOR=no - HAS_SERVO=yes - SETUP_SELECTOR_TOUCH=no - - _hw_mmu_vendor="PicoMMU" - _hw_mmu_version="1.0" - _hw_selector_type=ServoSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=0 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=0 - _hw_gear_gear_ratio="1.25:1" - _hw_gear_run_current=0.7 - _hw_gear_hold_current=0.1 - _hw_chain_count=4 - _hw_exit_leds="neopixel:mmu_leds (1-4)" - _hw_entry_leds="" - _hw_status_leds="" - _hw_logo_leds="" - - _param_extruder_homing_endstop="none" - _param_gate_homing_endstop="mmu_gate" - _param_gate_homing_max=100 - _param_gate_parking_distance=25 - _param_gear_homing_speed=80 - ;; - - "$QUATTRO_BOX") - HAS_ENCODER=yes - HAS_SELECTOR=no - HAS_SERVO=no - ADDONS_EJECT_BUTTONS=1 - - # mmu_hardware config - _hw_mmu_vendor="QuattroBox" - _hw_mmu_version="1.0" - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=1 - _hw_gear_gear_ratio="50:17" - _hw_gear_run_current=1.27 - _hw_gear_hold_current=0.2 - _hw_chain_count=32 - _hw_color_order="GRBW,GRBW,GRBW,GRBW,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB" - _hw_exit_leds="neopixel:mmu_leds (1-4)" - _hw_status_leds="neopixel:mmu_leds (5-14)" - _hw_logo_leds="neopixel:mmu_leds (15-32)" - - # mmu_parameters config - _param_extruder_homing_endstop="collision" - _param_gate_homing_endstop="mmu_gate" - _param_gate_homing_max=200 - _param_gate_preload_homing_max=200 - _param_gate_unload_buffer=50 - _param_gate_parking_distance=30 - _param_gate_endstop_to_encoder=18 - _param_gate_autoload=1 - _param_gate_final_eject_distance=200 - _param_has_filament_buffer=0 - - # mmu_macro_vars config - variable_default_status_effect='1, 0.15, 0.66' - variable_default_logo_effect='1, 0.15, 0.66' - ;; - - "$QUATTRO_BOX11") - HAS_ENCODER=yes - HAS_SELECTOR=no - HAS_SERVO=no - ADDONS_EJECT_BUTTONS=1 - - # mmu_hardware config - _hw_mmu_vendor="QuattroBox" - _hw_mmu_version="1.0" - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=1 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=1 - _hw_gear_gear_ratio="50:17" - _hw_gear_run_current=1.27 - _hw_gear_hold_current=0.2 - _hw_chain_count=36 - _hw_color_order="GRBW,GRBW,GRBW,GRBW,GRBW,GRBW,GRBW,GRBW,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB,GRB" - _hw_exit_leds="neopixel:mmu_leds (2,4,6,8)" - _hw_entry_leds="neopixel:mmu_leds (1,3,5,7)" - _hw_status_leds="neopixel:mmu_leds (9-36)" - - # mmu_parameters config - _param_extruder_homing_endstop="collision" - _param_gate_homing_endstop="mmu_gate" - _param_gate_homing_max=200 - _param_gate_preload_homing_max=200 - _param_gate_unload_buffer=50 - _param_gate_parking_distance=30 - _param_gate_endstop_to_encoder=18 - _param_gate_autoload=1 - _param_gate_final_eject_distance=200 - _param_has_filament_buffer=0 - - # mmu_macro_vars config - variable_default_exit_effect="gate_status_exit" - variable_default_entry_effect="gate_status" - variable_default_status_effect="filament_color" - ;; - - "$MMX") - HAS_ENCODER=no - HAS_SELECTOR=no - HAS_SERVO=yes - SETUP_SELECTOR_TOUCH=no - - _hw_mmu_vendor="MMX" - _hw_mmu_version="1.0" - _hw_selector_type=ServoSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=0 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=1 - _hw_gear_gear_ratio="80:20" - _hw_gear_run_current=0.7 - _hw_gear_hold_current=0.1 - _hw_chain_count=4 - _hw_exit_leds="neopixel:mmu_leds (4-1)" - _hw_entry_leds="" - _hw_status_leds="" - _hw_logo_leds="" - - _param_extruder_homing_endstop="none" - _param_gate_homing_endstop="mmu_gate" - _param_gate_homing_max=1000 - _param_gate_preload_homing_max=1000 - _param_gate_parking_distance=25 - _param_gear_homing_speed=80 - _param_selector_gate_angles="60,0,180,120" - ;; - - "$VVD") - # Comming soon (Bigtreetech)... - HAS_ENCODER=no - HAS_SELECTOR=yes - HAS_SERVO=no - HAS_ESPOOLER=yes - SETUP_LED=yes - # Note VVD has preconfigured mmu_hardware.cfg based on dedicated electronics - _hw_num_gates=4 - _hw_mmu_vendor="VVD" - _hw_mmu_version="1.0" - _hw_selector_type=IndexedSelector - - # mmu_parameters config - _param_extruder_homing_endstop="filament_compression" - _param_extruder_homing_max=250 - _param_extruder_homing_buffer=80 - _param_gate_homing_endstop="mmu_gear" - _param_gate_homing_max=250 - _param_gate_unload_buffer=80 - _param_gate_parking_distance=30 - _param_gate_preload_homing_max=750 - _param_gate_preload_parking_distance=30 - _param_gate_final_eject_distance=750 - _param_gate_autoload=1 - _param_has_filament_buffer=0 - - _param_autocal_bowden_length=1 - _param_autotune_bowden_length=0 - _param_skip_cal_rotation_distance=1 - _param_autotune_rotation_distance=1 - - _param_sync_feedback_enabled=1 - _param_sync_feedback_buffer_range=8 - _param_sync_feedback_buffer_maxrange=12 - - _param_update_aht10_commands=1 - ;; - - "$KMS") - HAS_ENCODER=yes - HAS_SELECTOR=no - HAS_SERVO=no - HAS_ESPOOLER=yes - SETUP_LED=yes - # Note KMS has preconfigured mmu_hardware.cfg based on dedicated electronics - _hw_num_gates=4 - _hw_mmu_vendor="KMS" - _hw_mmu_version="1.0" - _hw_selector_type=VirtualSelector - - # mmu_parameters config - _param_extruder_homing_endstop="filament_compression" - _param_gate_homing_endstop="mmu_gate" - _param_gate_homing_max=300 - _param_gate_preload_homing_max=300 - _param_gate_preload_parking_distance=-10 - _param_gate_parking_distance=20 - _param_gate_unload_buffer=50 - _param_gate_endstop_to_encoder=14 - _param_gate_autoload=1 - _param_gate_final_eject_distance=300 - _param_has_filament_buffer=0 - - _param_autocal_bowden_length=1 - _param_autotune_bowden_length=0 - _param_skip_cal_rotation_distance=0 - _param_autotune_rotation_distance=1 - _param_skip_cal_encoder=0 - _param_autotune_encoder=0 - - _param_sync_feedback_enabled=1 - _param_sync_feedback_buffer_range=8 - _param_sync_feedback_buffer_maxrange=12 - ;; - - *) - HAS_ENCODER=yes - HAS_SELECTOR=yes - HAS_SERVO=yes - HAS_ESPOOLER=yes - SETUP_LED=yes - SETUP_SELECTOR_TOUCH=no - - _hw_mmu_vendor="Other" - _hw_mmu_version="1.0" - _hw_selector_type=LinearSelector - _hw_variable_bowden_lengths=0 - _hw_variable_rotation_distances=0 - _hw_require_bowden_move=1 - _hw_filament_always_gripped=0 - _hw_gear_gear_ratio="1:1" - _hw_gear_run_current=0.7 - _hw_gear_hold_current=0.1 - _hw_sel_gear_ratio="1:1" - _hw_sel_run_current=0.5 - _hw_sel_hold_current=0.1 - - # This isn't meant to be all-inclusive of options. It is just to provide a config starting point that is close - echo -e "${PROMPT}${SECTION}Which of these most closely resembles your MMU design (this allows for some tuning of config files)?{$INPUT}" - OPTIONS=() # reset option array - option TYPE_A_WITH_ENCODER 'Type-A (selector) with Encoder' - option TYPE_A_NO_ENCODER 'Type-A (selector), No Encoder' - option TYPE_A_NO_ENCODER_NO_SERVO_NO_ESPOOLER 'Type-A (selector), No Encoder, No Servo, No ESpooler' - option TYPE_B_WITH_ENCODER 'Type-B (mutliple filament drive steppers) with Encoder' - option TYPE_B_WITH_SHARED_GATE_AND_ENCODER 'Type-B (multiple filament drive steppers) with shared Gate sensor and Encoder' - option TYPE_B_WITH_SHARED_GATE_NO_ENCODER 'Type-B (multiple filament drive steppers) with shared Gate sensor, No Encoder' - option TYPE_B_WITH_INDIVIDUAL_GEAR_SENSOR_AND_ENCODER 'Type-B (multiple filament drive steppers) with individual post-gear sensors and Encoder' - option TYPE_B_WITH_INDIVIDUAL_GEAR_SENSOR_NO_ENCODER 'Type-B (multiple filament drive steppers) with individual post-gear sensors, No Encoder' - option OTHER 'Just turn on all options and let me configure' - prompt_option opt 'Type' "${OPTIONS[@]}" - case "$opt" in - "$TYPE_A_WITH_ENCODER") - _param_gate_homing_endstop="encoder" - _param_extruder_homing_endstop="collision" - echo - echo -e "${WARNING} IMPORTANT: Since you have a custom MMU with selector you will need to setup some CAD dimensions in mmu_parameters.cfg... See doc" - ;; - "$TYPE_A_NO_ENCODER") - HAS_ENCODER=no - _param_gate_homing_endstop="mmu_gate" - _param_extruder_homing_endstop="none" - echo - echo -e "${WARNING} IMPORTANT: Since you have a custom MMU with selector you will need to setup some CAD dimensions in mmu_parameters.cfg... See doc" - ;; - "$TYPE_A_NO_ENCODER_NO_SERVO_NO_ESPOOLER") - HAS_ENCODER=no - HAS_SERVO=no - HAS_ESPOOLER=no - _param_gate_homing_endstop="mmu_gate" - _param_extruder_homing_endstop="none" - echo - echo -e "${WARNING} IMPORTANT: Since you have a custom MMU with selector you will need to setup some CAD dimensions in mmu_parameters.cfg... See doc" - ;; - "$TYPE_B_WITH_ENCODER") - HAS_SELECTOR=no - HAS_SERVO=no - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=1 - _hw_variable_rotation_distances=1 - _hw_filament_always_gripped=1 - _param_gate_homing_endstop="mmu_gate" - _param_extruder_homing_endstop="none" - ;; - "$TYPE_B_WITH_SHARED_GATE_AND_ENCODER") - HAS_SELECTOR=no - HAS_SERVO=no - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=1 - _hw_variable_rotation_distances=1 - _hw_filament_always_gripped=1 - _param_gate_homing_endstop="mmu_gate" - _param_extruder_homing_endstop="none" - ;; - "$TYPE_B_WITH_SHARED_GATE_NO_ENCODER") - HAS_SELECTOR=no - HAS_SERVO=no - HAS_ENCODER=no - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=1 - _hw_variable_rotation_distances=1 - _hw_filament_always_gripped=1 - _param_gate_homing_endstop="mmu_gate" - _param_extruder_homing_endstop="none" - ;; - "$TYPE_B_WITH_INDIVIDUAL_GEAR_SENSOR_AND_ENCODER") - HAS_SELECTOR=no - HAS_SERVO=no - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=1 - _hw_variable_rotation_distances=1 - _hw_filament_always_gripped=1 - _param_gate_homing_endstop="mmu_gear" - _param_extruder_homing_endstop="none" - ;; - "$TYPE_B_WITH_INDIVIDUAL_GEAR_SENSOR_NO_ENCODER") - HAS_SELECTOR=no - HAS_SERVO=no - HAS_ENCODER=no - HAS_ESPOOLER=yes - _hw_selector_type=VirtualSelector - _hw_variable_bowden_lengths=1 - _hw_variable_rotation_distances=1 - _hw_filament_always_gripped=1 - _param_gate_homing_endstop="mmu_gear" - _param_extruder_homing_endstop="none" - ;; - *) - _param_gate_homing_endstop="mmu_gate" - _param_extruder_homing_endstop="none" - ;; - esac - ;; - esac - - if [ "${_hw_mmu_vendor}" != "KMS" -a "${_hw_mmu_vendor}" != "VVD" ]; then - echo -e "${PROMPT}${SECTION}How many gates (lanes) do you have?${INPUT}" - _hw_num_gates=$(prompt_123 "Number of gates") - fi - - if [ "${_hw_mmu_vendor}" == "KMS" ]; then - pattern="Klipper_stm32" - for line in `ls /dev/serial/by-id 2>/dev/null | grep -E "Klipper_"`; do - if echo ${line} | grep -q "${pattern}"; then - echo -e "${PROMPT}${SECTION}Is '/dev/serial/by-id/${line}' a ${EMPHASIZE}KMS${PROMPT} controller serial port?${INPUT}" - OPTIONS=() - option KMS 'KMS MMU' - option BUFFER 'KMS Buffer (sync-feedback sensor)' - option NEITHER 'No, not related to KMS' - prompt_option opt 'KMS MCU?' "${OPTIONS[@]}" - case $opt in - "$KMS") - _hw_serial1="/dev/serial/by-id/${line}" - ;; - "$BUFFER") - _hw_serial2="/dev/serial/by-id/${line}" - ;; - *) - ;; - esac - fi - done - if [ "${_hw_serial1}" == "" ]; then - echo - echo -e "${WARNING} Couldn't find your MMU serial port, but no worries - I'll configure the default and you can manually change later" - _hw_serial1='/dev/ttyACM1 # Config guess. Run ls -l /dev/serial/by-id and set manually' - fi - if [ "${_hw_serial2}" == "" ]; then - echo - echo -e "${WARNING} Couldn't find your Bufffer (sync-feedback sensor) serial port, but no worries - I'll configure the default and you can manually change later" - _hw_serial1='/dev/ttyACM2 # Config guess. Run ls -l /dev/serial/by-id and set manually' - fi - - elif [ "${_hw_mmu_vendor}" == "VVD" ]; then - pattern="Klipper_stm32" - for line in `ls /dev/serial/by-id 2>/dev/null | grep -E "Klipper_"`; do - if echo ${line} | grep -q "${pattern}"; then - echo -e "${PROMPT}${SECTION}Is '/dev/serial/by-id/${line}' a ${EMPHASIZE}KMS${PROMPT} controller serial port?${INPUT}" - OPTIONS=() - option VVD 'ViVid MMU' - option BUFFER 'ViViD Buffer (sync-feedback sensor)' - option NEITHER 'No, not related to ViViD' - prompt_option opt 'ViViD MCU?' "${OPTIONS[@]}" - case $opt in - "$VVD") - _hw_serial1="/dev/serial/by-id/${line}" - ;; - "$BUFFER") - _hw_serial2="/dev/serial/by-id/${line}" - ;; - *) - ;; - esac - fi - done - if [ "${_hw_serial1}" == "" ]; then - echo - echo -e "${WARNING} Couldn't find your MMU serial port, but no worries - I'll configure the default and you can manually change later" - _hw_serial1='/dev/ttyACM1 # Config guess. Run ls -l /dev/serial/by-id and set manually' - fi - if [ "${_hw_serial2}" == "" ]; then - echo - echo -e "${WARNING} Couldn't find your Bufffer (sync-feedback sensor) serial port, but no worries - I'll configure the default and you can manually change later" - _hw_serial1='/dev/ttyACM2 # Config guess. Run ls -l /dev/serial/by-id and set manually' - fi - - else - _hw_brd_type="unknown" - echo -e "${PROMPT}${SECTION}Select mcu board type used to control MMU${INPUT}" - # Perhaps consider just supporting the BTT MMB (and eventually AFC) when mmu_vendor is BoxTurtle - # as many of these other boards may not work (due lack of exposed gpio) - OPTIONS=() - option MMB10 'BTT MMB v1.0 (with CANbus)' - option MMB11 'BTT MMB v1.1 (with CANbus)' - option MMB20 'BTT MMB v2.0 (with CANbus)' - option FYSETC_BURROWS_ERB_1 'Fysetc Burrows ERB v1' - option FYSETC_BURROWS_ERB_2 'Fysetc Burrows ERB v2' - option EASY_BRD_SAMD21 'Standard EASY-BRD (with SAMD21)' - option EASY_BRD_RP2040 'EASY-BRD with RP2040' - option MELLOW_BRD_1 'Mellow EASY-BRD v1.x (with CANbus)' - option MELLOW_BRD_2 'Mellow EASY-BRD v2.x (with CANbus)' - option TZB_1 'TZB v1.0' - option AFC_LITE_1 'AFC Lite v1.0' - option WGB_3 'WGB v3.0' - option SKR_PICO_1 'BTT SKR Pico v1.0' - option EBB42_12 'BTT EBB 42 CANbus v1.2 (for MMX or Pico)' - option OTHER 'Not in list / Unknown' - prompt_option opt 'MCU Type' "${OPTIONS[@]}" - case $opt in - "$MMB10") - _hw_brd_type="MMB10" - pattern="Klipper_stm32" - ;; - "$MMB11") - _hw_brd_type="MMB11" - pattern="Klipper_stm32" - ;; - "$MMB20") - _hw_brd_type="MMB20" - pattern="Klipper_stm32" - ;; - "$FYSETC_BURROWS_ERB_1") - _hw_brd_type="ERB" - pattern="Klipper_rp2040" - ;; - "$FYSETC_BURROWS_ERB_2") - _hw_brd_type="ERBv2" - pattern="Klipper_rp2040" - ;; - "$EASY_BRD_SAMD21") - _hw_brd_type="EASY-BRD" - pattern="Klipper_samd21" - ;; - "$EASY_BRD_RP2040") - _hw_brd_type="EASY-BRD-RP2040" - pattern="Klipper_rp2040" - ;; - "$MELLOW_BRD_1") - _hw_brd_type="MELLOW-EASY-BRD-CAN" - pattern="Klipper_rp2040" - ;; - "$MELLOW_BRD_2") - _hw_brd_type="MELLOW-EASY-BRD-CANv2" - pattern="Klipper_rp2040" - ;; - "$TZB_1") - _hw_brd_type="TZB_1" - pattern="Klipper_stm32" - ;; - "$AFC_LITE_1") - _hw_brd_type="AFC_LITE_1" - pattern="Klipper_stm32" - ;; - "$WGB_3") - _hw_brd_type="WGB_3" - pattern="Klipper_stm32" - ;; - "$SKR_PICO_1") - _hw_brd_type="SKR_PICO_1" - pattern="Klipper_rp2040" - ;; - "$EBB42_12") - _hw_brd_type="EBB42_12" - pattern="Klipper_" - ;; - *) - _hw_brd_type="unknown" - pattern="Klipper_" - ;; - esac - - for line in `ls /dev/serial/by-id 2>/dev/null | grep -E "Klipper_"`; do - if echo ${line} | grep -q "${pattern}"; then - echo -e "${PROMPT}${SECTION}This looks like your ${EMPHASIZE}${_hw_brd_type}${PROMPT} controller serial port. Is that correct?${INPUT}" - yn=$(prompt_yn "/dev/serial/by-id/${line}") - echo - case $yn in - y) - _hw_serial="/dev/serial/by-id/${line}" - break - ;; - n) - ;; - esac - fi - done - if [ "${_hw_serial}" == "" ]; then - echo - echo -e "${WARNING} Couldn't find your serial port, but no worries - I'll configure the default and you can manually change later" - _hw_serial='/dev/ttyACM1 # Config guess. Run ls -l /dev/serial/by-id and set manually' - fi - - # Avoid pin duplication. Most type-A MMU's have either encoder or gate. If both, user will have to fix - if [ "${HAS_ENCODER}" == "yes" ]; then - eval PIN[${_hw_brd_type},gate_sensor_pin]="" - else - eval PIN[${_hw_brd_type},encoder_pin]="" - fi - - echo -e "${PROMPT}${SECTION}Would you like to have neopixel LEDs setup now for your MMU?${INPUT}" - yn=$(prompt_yn "Enable LED support?") - echo - case $yn in - y) - SETUP_LED=yes - ;; - n) - SETUP_LED=no - ;; - esac - - if [ "${HAS_SELECTOR}" == "yes" ]; then - - if [ "$SETUP_SELECTOR_TOUCH" != "no" ]; then - echo -e "${PROMPT}${SECTION}Touch selector operation using TMC Stallguard? This allows for additional selector recovery steps but is difficult to tune" - echo -e "Not recommend if you are new to MMU/Happy Hare & MCU must have DIAG output for selector stepper. Can configure later${INPUT}" - yn=$(prompt_yn "Enable selector touch operation") - echo - case $yn in - y) - if [ "${_hw_brd_type}" == "EASY-BRD" ]; then - echo - echo -e "${WARNING} IMPORTANT: Set the J6 jumper pins to 2-3 and 4-5, i.e. .[..][..] MAKE A NOTE NOW!!" - fi - SETUP_SELECTOR_TOUCH=yes - ;; - n) - if [ "${_hw_brd_type}" == "EASY-BRD" ]; then - echo - echo -e "${WARNING} IMPORTANT: Set the J6 jumper pins to 1-2 and 4-5, i.e. [..].[..] MAKE A NOTE NOW!!" - fi - SETUP_SELECTOR_TOUCH=no - ;; - esac - fi - - if [ "$SETUP_SELECTOR_TOUCH" == "no" ]; then - echo -e "${PROMPT}${SECTION}Selector homing using TMC Stallguard? This prevents the need for hard endstop homing but must be tuned" - echo -e "MCU must have DIAG output for selector stepper. Can configure later${INPUT}" - yn=$(prompt_yn "Enable selector stallguard homing") - echo - case $yn in - y) - SETUP_SELECTOR_STALLGUARD_HOMING=yes - ;; - n) - SETUP_SELECTOR_STALLGUARD_HOMING=no - ;; - esac - fi - fi - - if [ "${HAS_SERVO}" == "yes" ]; then - - if [ "${_hw_mmu_vendor}" == "ERCF" ]; then - echo -e "${PROMPT}${SECTION}Which servo are you using?${INPUT}" - OPTIONS=() - option MG90S 'MG-90S' - option SH0255MG 'Savox SH0255MG' - option DS041MG 'GDW DS041MG' - option OTHER 'Not listed / Other' - prompt_option opt 'Servo' "${OPTIONS[@]}" - case $opt in - "$MG90S") - _hw_maximum_servo_angle=180 - _hw_minimum_pulse_width=0.00085 - _hw_maximum_pulse_width=0.00215 - _param_servo_always_active=0 - _param_servo_up_angle=30 - if [ "${_hw_mmu_version}" == "2.0" ]; then - _param_servo_move_angle=61 - else - _param_servo_move_angle=${_param_servo_up_angle} - fi - _param_servo_down_angle=140 - ;; - "$SH0255MG") - _hw_maximum_servo_angle=180 - _hw_minimum_pulse_width=0.00085 - _hw_maximum_pulse_width=0.00215 - _param_servo_always_active=0 - _param_servo_up_angle=140 - if [ "${_hw_mmu_version}" == "2.0" ]; then - _param_servo_move_angle=109 - else - _param_servo_move_angle=${_param_servo_up_angle} - fi - _param_servo_down_angle=30 - ;; - "$DS041MG") - _hw_maximum_servo_angle=180 - _hw_minimum_pulse_width=0.00050 - _hw_maximum_pulse_width=0.00250 - _param_servo_always_active=1 - _param_servo_up_angle=30 - if [ "${_hw_mmu_version}" == "2.0" ]; then - _param_servo_move_angle=50 - else - _param_servo_move_angle=${servo_up_angle} - fi - _param_servo_down_angle=100 - ;; - *) - _hw_maximum_servo_angle=180 - _hw_minimum_pulse_width=0.00085 - _hw_maximum_pulse_width=0.00215 - _param_servo_always_active=0 - ;; - esac - - elif [ "${_hw_mmu_vendor}" == "Tradrack" ]; then - echo -e "${PROMPT}${SECTION}Which servo are you using?${INPUT}" - OPTIONS=() - option TRADRACK_BOM 'PS-1171MG or FT1117M (Tradrack)' - option OTHER 'Not listed / Other' - prompt_option opt 'Servo' "${OPTIONS[@]}" - case $opt in - "$TRADRACK_BOM") - _hw_maximum_servo_angle=131 - _hw_minimum_pulse_width=0.00070 - _hw_maximum_pulse_width=0.00220 - _param_servo_always_active=1 - _param_servo_up_angle=145 - _param_servo_move_angle=${servo_up_angle} - _param_servo_down_angle=1 - ;; - *) - _hw_maximum_servo_angle=131 - _hw_minimum_pulse_width=0.00070 - _hw_maximum_pulse_width=0.00230 - _param_servo_always_active=1 - _param_servo_up_angle=145 - _param_servo_move_angle=${servo_up_angle} - _param_servo_down_angle=1 - ;; - esac - - elif [ "${_hw_mmu_vendor}" == "PicoMMU" -o "${_hw_mmu_vendor}" == "MMX" ]; then - echo -e "${PROMPT}${SECTION}Which servo are you using?${INPUT}" - OPTIONS=() - option MMX_BOM 'MG996R' - option EMAX_ES3004 'EMAX ES3004' - option OTHER 'Not listed / Other' - prompt_option opt 'Servo' "${OPTIONS[@]}" - case $opt in - "$MMX_BOM") - _hw_maximum_servo_angle=180 - _hw_minimum_pulse_width=0.00070 - _hw_maximum_pulse_width=0.00230 - _param_servo_always_active=0 - _param_servo_duration=0.6 - _param_servo_dwell=1.0 - ;; - "$EMAX_ES3004") - _hw_maximum_servo_angle=140 - _hw_minimum_pulse_width=0.00070 - _hw_maximum_pulse_width=0.00230 - _param_servo_always_active=0 - _param_servo_duration=0.6 - _param_servo_dwell=1.2 - ;; - *) - _hw_maximum_servo_angle=180 - _hw_minimum_pulse_width=0.001 - _hw_maximum_pulse_width=0.002 - _param_servo_always_active=1 - _param_servo_duration=0.6 - _param_servo_dwell=1.0 - ;; - esac - - else - # Other (unknown) vendor - _hw_maximum_servo_angle=180 - _hw_minimum_pulse_width=0.001 - _hw_maximum_pulse_width=0.002 - _param_servo_always_active=0 - _param_servo_up_angle=0 - _param_servo_move_angle=0 - _param_servo_down_angle=0 - fi - fi - fi - - if [ "${HAS_ENCODER}" == "yes" ]; then - echo -e "${PROMPT}${SECTION}Clog detection? This uses the MMU encoder movement to detect clogs and can call your filament runout logic${INPUT}" - yn=$(prompt_yn "Enable clog detection") - echo - case $yn in - y) - _param_enable_clog_detection=1 - echo -e "${PROMPT} Would you like MMU to automatically adjust clog detection length (recommended)?${INPUT}" - yn=$(prompt_yn " Automatic") - echo - if [ "${yn}" == "y" ]; then - _param_enable_clog_detection=2 - fi - ;; - n) - _param_enable_clog_detection=0 - ;; - esac - else - _param_enable_clog_detection=0 - fi - - echo -e "${PROMPT}${SECTION}EndlessSpool? This uses filament runout detection to automate switching to new spool without interruption${INPUT}" - yn=$(prompt_yn "Enable EndlessSpool") - echo - case $yn in - y) - _param_enable_endless_spool=1 - ;; - n) - _param_enable_endless_spool=0 - ;; - esac - - echo -e "${PROMPT}${SECTION}Finally, would you like me to include all the MMU config files into your ${PRINTER_CONFIG} file${INPUT}" - yn=$(prompt_yn "Add include?") - echo - case $yn in - y) - INSTALL_PRINTER_INCLUDES=yes - echo -e "${PROMPT} Would you like to include Mini 12864 screen menu configuration extension for MMU (only if you have one!)${INPUT}" - yn=$(prompt_yn " Include menu") - echo - case $yn in - y) - MENU_12864=1 - ;; - n) - MENU_12864=0 - ;; - esac - - echo -e "${PROMPT} Recommended: Would you like to include the default pause/resume macros supplied with Happy Hare${INPUT}" - yn=$(prompt_yn " Include client_macros.cfg") - echo - case $yn in - y) - CLIENT_MACROS=1 - ;; - n) - CLIENT_MACROS=0 - ;; - esac - - echo -e "${PROMPT} Addons: Would you like to include the EREC filament cutter macro (requires EREC servo installation)${INPUT}" - yn=$(prompt_yn " Include mmu_erec_cutter.cfg") - echo - case $yn in - y) - ADDONS_EREC=1 - ;; - n) - ADDONS_EREC=0 - ;; - esac - - echo -e "${PROMPT} Addons: Would you like to include the Blobifier purge system (requires Blobifier servo installation)${INPUT}" - yn=$(prompt_yn " Include blobifier.cfg") - echo - case $yn in - y) - ADDONS_BLOBIFIER=1 - ;; - n) - ADDONS_BLOBIFIER=0 - ;; - esac - ;; - n) - INSTALL_PRINTER_INCLUDES=no - ;; - esac - -# Too verbose.. -# echo -e "${EMPHASIZE}" -# echo -e "Summary of hardware config set by questionaire:${INFO}" -# for var in $(set | grep '^_hw_' | cut -d '=' -f 1); do -# short_name=$(echo "$var" | sed 's/^_hw_//') -# eval "echo -e \"$short_name: \${$var}\"" -# done - - echo -e "${INFO}" - echo " vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv" - echo - echo " NOTES:" - echo " What still needs to be done:" - echo " Edit mmu.cfg and mmu_hardware.cfg to get hardware correctly setup" - if [ "${_hw_brd_type}" == "unknown" ]; then - echo " * Edit *.cfg files and substitute all missing pins" - else - echo " * Review all pin configuration and change to match your mcu" - fi - echo " * Verify motor current, especially if using non BOM motors" - echo " * Adjust motor direction with '!' on pin if necessary. No way to know here" - echo " * Adjust your config for loading and unloading preferences" - echo -e "${WARNING}" - echo " Make sure you that you have these near the top of your printer.cfg:" - echo " # Happy Hare" - echo " [include mmu/base/*.cfg]" - echo " [include mmu/optional/client_macros.cfg]" - echo " [include mmu/addons/blobifier.cfg]" - echo " [include mmu/addons/mmu_erec_cutter.cfg]" - if [ "${ADDONS_EJECT_BUTTONS:-0}" -eq 1 ]; then - echo " [include mmu/addons/mmu_eject_buttons.cfg]" - fi - echo -e "${INFO}" - echo " Later:" - echo " * Tweak configurations like speed and distance in mmu_parameters.cfg" - echo " * Configure your operational preferences in mmu_macro_vars.cfg" - echo - echo " Good luck! MMU is complex to setup. Remember Discord is your friend.." - echo -e " Join the dedicated Happy Hare forum here: ${EMPHASIZE}https://discord.gg/98TYYUf6f2${INFO}" - if [ -n "${HELP_URL}" ]; then - echo -e " Make sure to follow these instructions while setting up your MMU:" - echo -e " ${EMPHASIZE}${HELP_URL}" - if [ -n "${HELP_URL_B}" ]; then - echo -e " ${EMPHASIZE}${HELP_URL_B}" - fi - fi - echo - echo " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^" - echo -} - -usage() { - echo -e "${EMPHASIZE}" - echo "Usage: $0 [-i] [-e] [-d] [-z] [-s]" - echo " [-b ] [-k ] [-c ] [-m ]" - echo " [-a ] [-r ]" - echo - echo "-i for interactive install" - echo "-e for install of default starter config files for manual configuration" - echo "-d for uninstall" - echo "-z skip github update check (nullifies -b )" - echo "-s to skip restart of services" - echo "-b to switch to specified feature branch (sticky)" - echo "-k to specify location of non-default klipper home directory" - echo "-c to specify location of non-default klipper config directory" - echo "-m to specify location of non-default moonraker home directory" - echo "-r specify Repetier-Server to override printer.cfg and klipper.service names" - echo "-a to specify alternative klipper-service-name when installed with Kiauh" - echo "(no flags for safe re-install / upgrade)" - echo - exit 1 -} - -# Find SRCDIR from the pathname of this script -SRCDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/ && pwd )" - -# Defaults for first time run without running interview/questionaire -_hw_brd_type="unknown" -_hw_serial="/dev/serial/by-id/XXX" -_hw_num_gates=12 -_hw_mmu_vendor=Custom -_hw_mmu_version=1.0 -_hw_selector_type=LinearSelector -_hw_variable_bowden_lengths=1 -_hw_variable_rotation_distances=1 -_hw_encoder_resolution=1.0 -SETUP_SELECTOR_TOUCH=no -SETUP_LED=yes -HAS_ENCODER=yes -HAS_SELECTOR=yes -ADDONS_EREC=0 -ADDONS_BLOBIFIER=0 -ADDONS_EJECT_BUTTONS=0 - -INSTALL=0 -UNINSTALL=0 -NOSERVICE=0 -STARTER=0 -PRINTER_CONFIG=printer.cfg -KLIPPER_SERVICE=klipper.service - -while getopts "a:b:k:c:m:r:idsze" arg; do - case $arg in - a) KLIPPER_SERVICE=${OPTARG}.service;; - b) N_BRANCH=${OPTARG};; - k) KLIPPER_HOME=${OPTARG};; - m) MOONRAKER_HOME=${OPTARG};; - c) KLIPPER_CONFIG_HOME=${OPTARG};; - r) PRINTER_CONFIG=${OPTARG}.cfg - KLIPPER_SERVICE=klipper_${OPTARG}.service - echo "Repetier-Server specified. Over-riding printer.cfg to [${PRINTER_CONFIG}] & klipper.service to [${KLIPPER_SERVICE}]" - ;; - i) INSTALL=1;; - d) UNINSTALL=1;; - e) STARTER=1;; - s) NOSERVICE=1;; - z) SKIP_UPDATE=1;; - *) usage;; - esac -done - -if [ "${INSTALL}" -eq 1 -a "${UNINSTALL}" -eq 1 ]; then - echo -e "${ERROR}Can't install and uninstall at the same time!" - usage -fi - -verify_not_root -[ -z "${SKIP_UPDATE}" ] && { - self_update # Make sure the repo is up-to-date on correct branch -} -check_octoprint -verify_home_dirs -check_klipper -cleanup_old_klippy_modules - -if [ "$UNINSTALL" -eq 0 ]; then - if [ "${INSTALL}" -eq 1 ]; then - echo -e "${TITLE}$(get_logo "Happy Hare interactive installer...")" - questionaire # Update in memory parameters from questionaire - read_default_config merge # Parses template file parameters and merges into memory - - if [ "${INSTALL_PRINTER_INCLUDES}" == "yes" ]; then - install_printer_includes - fi - else - hardware_config="${KLIPPER_CONFIG_HOME}/mmu/base/mmu_hardware.cfg" - if [ -f "${hardware_config}" ]; then - echo -e "${TITLE}$(get_logo "Happy Hare upgrading previous install...")" - read_previous_mmu_type # Get MMU type info first - read_default_config # Parses template file parameters into memory - read_previous_config # Update in memory parameters from previous install - elif [ "${STARTER}" -eq 0 ]; then - echo -e "${ERROR}Nothing to upgrade. If you want a new install run with '-i' (interactive) or '-e' (empty config)" - usage - else - # Starter blank install - echo -e "${TITLE}$(get_logo "Happy Hare generating skeletal config...")" - read_default_config # Parses template file parameters into memory - fi - fi - - # Important to update version - FROM_VERSION=${_param_happy_hare_version} - if [ ! "${FROM_VERSION}" == "" ]; then - downgrade=$(awk -v to="$VERSION" -v from="$FROM_VERSION" 'BEGIN {print (to < from) ? "1" : "0"}') - bad_v2v3=$(awk -v to="$VERSION" -v from="$FROM_VERSION" 'BEGIN {print (from < 2.70 && to >= 3.0) ? "1" : "0"}') - if [ "$downgrade" -eq 1 ]; then - echo -e "${WARNING}Trying to update from version ${FROM_VERSION} to ${VERSION}" - echo -e "${ERROR}Automatic 'downgrade' to earlier version is not garanteed. If you encounter startup problems you may" - echo -e "${ERROR}need to manually compare the backed-up 'mmu_parameters.cfg' with current one to restore differences" - elif [ "$bad_v2v3" -eq 1 ]; then - echo -e "${ERROR}Cannot automatically 'upgrade' from version ${FROM_VERSION} to ${VERSION}..." - echo -e "${ERROR}Please upgrade to v2.7.0 or later before attempting v3.0 upgrade" - exit 1 - elif [ ! "${FROM_VERSION}" == "${VERSION}" ]; then - echo -e "${WARNING}Upgrading from version ${FROM_VERSION} to ${VERSION}..." - fi - fi - _param_happy_hare_version=${VERSION} - - # Copy config files updating from in memory parmameters or h/w settings - set +e - copy_config_files - remove_old_config_files - set -e - - # Special upgrades of mmu_hardware.cfg - upgrade_mmu_hardware - - # Link in new components - link_mmu_plugins - install_update_manager - -else - echo - echo -e "${WARNING}You have asked me to remove Happy Hare and cleanup" - echo - yn=$(prompt_yn "Are you sure you want to proceed with deleting Happy Hare?") - echo - case $yn in - y) - unlink_mmu_plugins - uninstall_update_manager - uninstall_printer_includes - uninstall_config_files - echo -e "${INFO}Uninstall complete except for the Happy-Hare directory - you can now safely delete that as well" - ;; - n) - echo -e "${INFO}Well that was a close call! Everything still intact" - echo - exit 0 - ;; - esac -fi - -if [ "$INSTALL" -eq 0 ]; then - if [ "$VERSION" == "2.70" -a "$FROM_VERSION" != "2.70" ]; then - restart_moonraker - fi - restart_klipper -else - echo -e "${WARNING}Klipper not restarted automatically because you need to validate and complete config first" -fi - -if [ "$UNINSTALL" -eq 1 ]; then - echo -e "${EMPHASIZE}" - echo "Done. Sad to see you go (but maybe you'll be back)..." - echo -e "${sad_logo}" -else - echo -e "${TITLE}Done." - echo -e "$(get_logo "Happy Hare ${F_VERSION} Ready...")" -fi diff --git a/klippy/extras/Happy-Hare/installer-dev/.gitignore b/klippy/extras/Happy-Hare/installer-dev/.gitignore deleted file mode 100644 index f733c4b5fb40..000000000000 --- a/klippy/extras/Happy-Hare/installer-dev/.gitignore +++ /dev/null @@ -1 +0,0 @@ -config/ diff --git a/klippy/extras/Happy-Hare/installer-dev/Dockerfile b/klippy/extras/Happy-Hare/installer-dev/Dockerfile deleted file mode 100644 index d012a8b218c6..000000000000 --- a/klippy/extras/Happy-Hare/installer-dev/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM mkuf/klipper:latest - -USER root - -RUN apt update && apt install -y make - -USER klipper diff --git a/klippy/extras/Happy-Hare/installer-dev/README.md b/klippy/extras/Happy-Hare/installer-dev/README.md deleted file mode 100644 index b9eaff7a6d0a..000000000000 --- a/klippy/extras/Happy-Hare/installer-dev/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Installer dev - -This provides a quick way to run through the installer in a docker environment, making it more portable. - -> [!NOTE] -> This will create/update configs at `/installer-dev/config`. You may then review the changes there -> or completely remove the files and start from scratch. - -## Usage - -### Full install - -This will run the installer with `-i` which forces it to run through the questionaire. - -```shell -cd ./installer-dev -docker compose run --build --rm install -``` - -### Upgrade - -This will run the installer without `-i` which will perform a config upgrade. - -```shell -cd ./installer-dev -docker compose run --build --rm upgrade -``` diff --git a/klippy/extras/Happy-Hare/installer-dev/docker-compose.yaml b/klippy/extras/Happy-Hare/installer-dev/docker-compose.yaml deleted file mode 100644 index 3f7f54d35fbe..000000000000 --- a/klippy/extras/Happy-Hare/installer-dev/docker-compose.yaml +++ /dev/null @@ -1,19 +0,0 @@ -services: - _base: &base - build: . - environment: - HOME: /opt - volumes: - - ./entrypoint.sh:/entrypoint.sh - - ./config:/opt/printer_data/config - - ../:/opt/Happy-Hare - entrypoint: /entrypoint.sh - command: ./install.sh -sz - - install: - << : *base - command: ./install.sh -siz - - upgrade: - << : *base - command: ./install.sh -sz diff --git a/klippy/extras/Happy-Hare/installer-dev/entrypoint.sh b/klippy/extras/Happy-Hare/installer-dev/entrypoint.sh deleted file mode 100755 index f06cc4b19d8a..000000000000 --- a/klippy/extras/Happy-Hare/installer-dev/entrypoint.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env sh - -set -x - -# there needs to be at least 1 line in printer.cfg for the installer to be able to add the includes -if [ ! -f "${HOME}/printer_data/config/printer.cfg" ]; then - echo '# Printer Config' >> "${HOME}/printer_data/config/printer.cfg" -fi -cd ~/Happy-Hare - -# run all the arguments as the command -exec "$@" diff --git a/klippy/extras/Happy-Hare/moonraker_update.txt b/klippy/extras/Happy-Hare/moonraker_update.txt deleted file mode 100644 index ec676271b001..000000000000 --- a/klippy/extras/Happy-Hare/moonraker_update.txt +++ /dev/null @@ -1,11 +0,0 @@ -[update_manager happy-hare] -type: git_repo -path: ~/Happy-Hare -origin: https://github.com/moggieuk/Happy-Hare.git -primary_branch: main -managed_services: klipper - -[mmu_server] -enable_file_preprocessor: True -enable_toolchange_next_pos: True -update_spoolman_location: True diff --git a/klippy/extras/Happy-Hare/pin_defs b/klippy/extras/Happy-Hare/pin_defs deleted file mode 100644 index 023ea7e9a6cc..000000000000 --- a/klippy/extras/Happy-Hare/pin_defs +++ /dev/null @@ -1,870 +0,0 @@ -#!/bin/bash -# Happy Hare MMU Software -# -# Pin definitions for Installer script. Includes complete set of definitions for each board type -# so impossible setups have blank pins rather than placeholder tokens. Each MCU defines a setup -# for type-A and some for type-B MMU styles. -# -# The defined pin tokens allow for up to 12 gates on type-A MMU's and 4 gates on type-B. No MCU -# can currently support beyond these limits -# -# Copyright (C) 2022-2024 moggieuk#6538 (discord) moggieuk@hotmail.com -# - -# Pins for original EASY-BRD and EASY-BRD with Seed Studio XIAO RP2040 -# Note: uart pin is shared on original EASY-BRD (with different uart addresses) -# -PIN[EASY-BRD,gear_uart_pin]="PA8"; PIN[EASY-BRD-RP2040,gear_uart_pin]="gpio6" -PIN[EASY-BRD,gear_step_pin]="PA4"; PIN[EASY-BRD-RP2040,gear_step_pin]="gpio27" -PIN[EASY-BRD,gear_dir_pin]="PA10"; PIN[EASY-BRD-RP2040,gear_dir_pin]="gpio28" -PIN[EASY-BRD,gear_enable_pin]="PA2"; PIN[EASY-BRD-RP2040,gear_enable_pin]="gpio26" -PIN[EASY-BRD,gear_diag_pin]=""; PIN[EASY-BRD-RP2040,gear_diag_pin]="" -PIN[EASY-BRD,gear_1_uart_pin]=""; PIN[EASY-BRD-RP2040,gear_1_uart_pin]=""; -PIN[EASY-BRD,gear_1_step_pin]=""; PIN[EASY-BRD-RP2040,gear_1_step_pin]=""; -PIN[EASY-BRD,gear_1_dir_pin]=""; PIN[EASY-BRD-RP2040,gear_1_dir_pin]=""; -PIN[EASY-BRD,gear_1_enable_pin]=""; PIN[EASY-BRD-RP2040,gear_1_enable_pin]=""; -PIN[EASY-BRD,gear_1_diag_pin]=""; PIN[EASY-BRD-RP2040,gear_1_diag_pin]=""; -PIN[EASY-BRD,gear_2_uart_pin]=""; PIN[EASY-BRD-RP2040,gear_2_uart_pin]=""; -PIN[EASY-BRD,gear_2_step_pin]=""; PIN[EASY-BRD-RP2040,gear_2_step_pin]=""; -PIN[EASY-BRD,gear_2_dir_pin]=""; PIN[EASY-BRD-RP2040,gear_2_dir_pin]=""; -PIN[EASY-BRD,gear_2_enable_pin]=""; PIN[EASY-BRD-RP2040,gear_2_enable_pin]=""; -PIN[EASY-BRD,gear_2_diag_pin]=""; PIN[EASY-BRD-RP2040,gear_2_diag_pin]=""; -PIN[EASY-BRD,gear_3_uart_pin]=""; PIN[EASY-BRD-RP2040,gear_3_uart_pin]=""; -PIN[EASY-BRD,gear_3_step_pin]=""; PIN[EASY-BRD-RP2040,gear_3_step_pin]=""; -PIN[EASY-BRD,gear_3_dir_pin]=""; PIN[EASY-BRD-RP2040,gear_3_dir_pin]=""; -PIN[EASY-BRD,gear_3_enable_pin]=""; PIN[EASY-BRD-RP2040,gear_3_enable_pin]=""; -PIN[EASY-BRD,gear_3_diag_pin]=""; PIN[EASY-BRD-RP2040,gear_3_diag_pin]=""; -PIN[EASY-BRD,selector_uart_pin]="PA8"; PIN[EASY-BRD-RP2040,selector_uart_pin]="gpio6" -PIN[EASY-BRD,selector_step_pin]="PA9"; PIN[EASY-BRD-RP2040,selector_step_pin]="gpio7" -PIN[EASY-BRD,selector_dir_pin]="PB8"; PIN[EASY-BRD-RP2040,selector_dir_pin]="gpio0" -PIN[EASY-BRD,selector_enable_pin]="PA11"; PIN[EASY-BRD-RP2040,selector_enable_pin]="gpio29" -PIN[EASY-BRD,selector_diag_pin]="PA7"; PIN[EASY-BRD-RP2040,selector_diag_pin]="gpio2" -PIN[EASY-BRD,selector_endstop_pin]="PB9"; PIN[EASY-BRD-RP2040,selector_endstop_pin]="gpio1" -PIN[EASY-BRD,selector_servo_pin]="PA5"; PIN[EASY-BRD-RP2040,selector_servo_pin]="gpio4" -PIN[EASY-BRD,encoder_pin]="PA6"; PIN[EASY-BRD-RP2040,encoder_pin]="gpio3" -PIN[EASY-BRD,neopixel_pin]=""; PIN[EASY-BRD-RP2040,neopixel_pin]="" -PIN[EASY-BRD,gate_sensor_pin]="PA6"; PIN[EASY-BRD-RP2040,gate_sensor_pin]="gpio3"; -PIN[EASY-BRD,pre_gate_0_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_0_pin]=""; -PIN[EASY-BRD,pre_gate_1_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_1_pin]=""; -PIN[EASY-BRD,pre_gate_2_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_2_pin]=""; -PIN[EASY-BRD,pre_gate_3_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_3_pin]=""; -PIN[EASY-BRD,pre_gate_4_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_4_pin]=""; -PIN[EASY-BRD,pre_gate_5_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_5_pin]=""; -PIN[EASY-BRD,pre_gate_6_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_6_pin]=""; -PIN[EASY-BRD,pre_gate_7_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_7_pin]=""; -PIN[EASY-BRD,pre_gate_8_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_8_pin]=""; -PIN[EASY-BRD,pre_gate_9_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_9_pin]=""; -PIN[EASY-BRD,pre_gate_10_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_10_pin]=""; -PIN[EASY-BRD,pre_gate_11_pin]=""; PIN[EASY-BRD-RP2040,pre_gate_11_pin]=""; -PIN[EASY-BRD,gear_sensor_0_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_0_pin]=""; -PIN[EASY-BRD,gear_sensor_1_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_1_pin]=""; -PIN[EASY-BRD,gear_sensor_2_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_2_pin]=""; -PIN[EASY-BRD,gear_sensor_3_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_3_pin]=""; -PIN[EASY-BRD,gear_sensor_4_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_4_pin]=""; -PIN[EASY-BRD,gear_sensor_5_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_5_pin]=""; -PIN[EASY-BRD,gear_sensor_6_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_6_pin]=""; -PIN[EASY-BRD,gear_sensor_7_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_7_pin]=""; -PIN[EASY-BRD,gear_sensor_8_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_8_pin]=""; -PIN[EASY-BRD,gear_sensor_9_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_9_pin]=""; -PIN[EASY-BRD,gear_sensor_10_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_10_pin]=""; -PIN[EASY-BRD,gear_sensor_11_pin]=""; PIN[EASY-BRD-RP2040,gear_sensor_10_pin]=""; -# -# Common Type-B MMU pin utilization -# -# TODO - - -# Pins for Mellow EASY-BRD with CANbus (original v1.x and v2) -# -PIN[MELLOW-EASY-BRD-CAN,gear_uart_pin]="gpio9"; PIN[MELLOW-EASY-BRD-CANv2,gear_uart_pin]="gpio9"; -PIN[MELLOW-EASY-BRD-CAN,gear_step_pin]="gpio7"; PIN[MELLOW-EASY-BRD-CANv2,gear_step_pin]="gpio7"; -PIN[MELLOW-EASY-BRD-CAN,gear_dir_pin]="gpio8"; PIN[MELLOW-EASY-BRD-CANv2,gear_dir_pin]="gpio8"; -PIN[MELLOW-EASY-BRD-CAN,gear_enable_pin]="gpio6"; PIN[MELLOW-EASY-BRD-CANv2,gear_enable_pin]="gpio6"; -PIN[MELLOW-EASY-BRD-CAN,gear_diag_pin]="gpio23"; PIN[MELLOW-EASY-BRD-CANv2,gear_diag_pin]=""; # v2: Dup with encoder (gpio15) -PIN[MELLOW-EASY-BRD-CAN,gear_1_uart_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_uart_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_1_step_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_step_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_1_dir_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_dir_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_1_enable_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_enable_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_1_diag_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_1_diag_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_2_uart_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_uart_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_2_step_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_step_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_2_dir_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_dir_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_2_enable_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_enable_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_2_diag_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_2_diag_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_3_uart_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_uart_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_3_step_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_step_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_3_dir_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_dir_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_3_enable_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_enable_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_3_diag_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_3_diag_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,selector_uart_pin]="gpio0"; PIN[MELLOW-EASY-BRD-CANv2,selector_uart_pin]="gpio2"; -PIN[MELLOW-EASY-BRD-CAN,selector_step_pin]="gpio2"; PIN[MELLOW-EASY-BRD-CANv2,selector_step_pin]="gpio4"; -PIN[MELLOW-EASY-BRD-CAN,selector_dir_pin]="gpio1"; PIN[MELLOW-EASY-BRD-CANv2,selector_dir_pin]="gpio3"; -PIN[MELLOW-EASY-BRD-CAN,selector_enable_pin]="gpio3"; PIN[MELLOW-EASY-BRD-CANv2,selector_enable_pin]="gpio5"; -PIN[MELLOW-EASY-BRD-CAN,selector_diag_pin]="gpio22"; PIN[MELLOW-EASY-BRD-CANv2,selector_diag_pin]="gpio20"; # v2: Dup with endstop (gpio20) -PIN[MELLOW-EASY-BRD-CAN,selector_endstop_pin]="gpio20"; PIN[MELLOW-EASY-BRD-CANv2,selector_endstop_pin]="gpio20"; # Endstop -PIN[MELLOW-EASY-BRD-CAN,selector_servo_pin]="gpio21"; PIN[MELLOW-EASY-BRD-CANv2,selector_servo_pin]="gpio21"; # Servo -PIN[MELLOW-EASY-BRD-CAN,encoder_pin]="gpio15"; PIN[MELLOW-EASY-BRD-CANv2,encoder_pin]="gpio15"; # Encoder -PIN[MELLOW-EASY-BRD-CAN,neopixel_pin]="gpio14"; PIN[MELLOW-EASY-BRD-CANv2,neopixel_pin]="gpio14"; # v1: Extra / v2: RGB -PIN[MELLOW-EASY-BRD-CAN,gate_sensor_pin]="gpio15"; PIN[MELLOW-EASY-BRD-CANv2,gate_sensor_pin]="gpio15"; # Encoder (Alt) -PIN[MELLOW-EASY-BRD-CAN,pre_gate_0_pin]="gpio10"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_0_pin]="gpio24"; # v1: Exp 5 / v2: Exp 3 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_1_pin]="gpio26"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_1_pin]="gpio22"; # v1: Exp 6 / v2: Exp 4 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_2_pin]="gpio11"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_2_pin]="gpio25"; # v1: Exp 7 / v2: Exp 5 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_3_pin]="gpio27"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_3_pin]="gpio23"; # v1: Exp 8 / v2: Exp 6 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_4_pin]="gpio12"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_4_pin]="gpio13"; # v1: Exp 9 / v2: Exp 7 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_5_pin]="gpio28"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_5_pin]="gpio26"; # v1: Exp 10 / v2: Exp 8 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_6_pin]="gpio24"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_6_pin]="gpio12"; # v1: Exp 11 / v2: Exp 9 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_7_pin]="gpio29"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_7_pin]="gpio27"; # v1: Exp 12 / v2: Exp 10 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_8_pin]="gpio13"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_8_pin]="gpio11"; # v1: Exp 13 / v2: Exp 11 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_9_pin]="gpio25"; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_9_pin]="gpio28"; # v1: Exp 14 / v2: Exp 12 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_10_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_10_pin]="gpio10"; # v2: Exp 13 -PIN[MELLOW-EASY-BRD-CAN,pre_gate_11_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,pre_gate_11_pin]="gpio29"; # v2: Exp 14 -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_0_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_0_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_1_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_1_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_2_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_2_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_3_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_3_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_4_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_4_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_5_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_5_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_6_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_6_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_7_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_7_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_8_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_8_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_9_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_9_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_10_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_10_pin]=""; -PIN[MELLOW-EASY-BRD-CAN,gear_sensor_11_pin]=""; PIN[MELLOW-EASY-BRD-CANv2,gear_sensor_10_pin]=""; -# -# Common Type-B MMU pin utilization -# -# TODO - - -# Pins for Fysetc Burrows ERB board (original v1 and v2) -# -PIN[ERB,gear_uart_pin]="gpio20"; PIN[ERBv2,gear_uart_pin]="gpio11"; -PIN[ERB,gear_step_pin]="gpio10"; PIN[ERBv2,gear_step_pin]="gpio10"; -PIN[ERB,gear_dir_pin]="gpio9"; PIN[ERBv2,gear_dir_pin]="gpio9"; -PIN[ERB,gear_enable_pin]="gpio8"; PIN[ERBv2,gear_enable_pin]="gpio8"; -PIN[ERB,gear_diag_pin]="gpio13"; PIN[ERBv2,gear_diag_pin]="gpio13"; -PIN[ERB,gear_1_uart_pin]=""; PIN[ERBv2,gear_1_uart_pin]=""; -PIN[ERB,gear_1_step_pin]=""; PIN[ERBv2,gear_1_step_pin]=""; -PIN[ERB,gear_1_dir_pin]=""; PIN[ERBv2,gear_1_dir_pin]=""; -PIN[ERB,gear_1_enable_pin]=""; PIN[ERBv2,gear_1_enable_pin]=""; -PIN[ERB,gear_1_diag_pin]=""; PIN[ERBv2,gear_1_diag_pin]=""; -PIN[ERB,gear_2_uart_pin]=""; PIN[ERBv2,gear_2_uart_pin]=""; -PIN[ERB,gear_2_step_pin]=""; PIN[ERBv2,gear_2_step_pin]=""; -PIN[ERB,gear_2_dir_pin]=""; PIN[ERBv2,gear_2_dir_pin]=""; -PIN[ERB,gear_2_enable_pin]=""; PIN[ERBv2,gear_2_enable_pin]=""; -PIN[ERB,gear_2_diag_pin]=""; PIN[ERBv2,gear_2_diag_pin]=""; -PIN[ERB,gear_3_uart_pin]=""; PIN[ERBv2,gear_3_uart_pin]=""; -PIN[ERB,gear_3_step_pin]=""; PIN[ERBv2,gear_3_step_pin]=""; -PIN[ERB,gear_3_dir_pin]=""; PIN[ERBv2,gear_3_dir_pin]=""; -PIN[ERB,gear_3_enable_pin]=""; PIN[ERBv2,gear_3_enable_pin]=""; -PIN[ERB,gear_3_diag_pin]=""; PIN[ERBv2,gear_3_diag_pin]=""; -PIN[ERB,selector_uart_pin]="gpio17"; PIN[ERBv2,selector_uart_pin]="gpio17"; -PIN[ERB,selector_step_pin]="gpio16"; PIN[ERBv2,selector_step_pin]="gpio16"; -PIN[ERB,selector_dir_pin]="gpio15"; PIN[ERBv2,selector_dir_pin]="gpio15"; -PIN[ERB,selector_enable_pin]="gpio14"; PIN[ERBv2,selector_enable_pin]="gpio14"; -PIN[ERB,selector_diag_pin]="gpio19"; PIN[ERBv2,selector_diag_pin]="gpio19"; -PIN[ERB,selector_endstop_pin]="gpio24"; PIN[ERBv2,selector_endstop_pin]="gpio24"; -PIN[ERB,selector_servo_pin]="gpio23"; PIN[ERBv2,selector_servo_pin]="gpio23"; -PIN[ERB,encoder_pin]="gpio22"; PIN[ERBv2,encoder_pin]="gpio22"; -PIN[ERB,neopixel_pin]="gpio21"; PIN[ERBv2,neopixel_pin]="gpio21"; -PIN[ERB,gate_sensor_pin]="gpio22"; PIN[ERBv2,gate_sensor_pin]="gpio25"; # Hall Effect -PIN[ERB,pre_gate_0_pin]="gpio0"; PIN[ERBv2,pre_gate_0_pin]="gpio12"; -PIN[ERB,pre_gate_1_pin]="gpio1"; PIN[ERBv2,pre_gate_1_pin]="gpio18"; -PIN[ERB,pre_gate_2_pin]="gpio2"; PIN[ERBv2,pre_gate_2_pin]="gpio2"; -PIN[ERB,pre_gate_3_pin]="gpio3"; PIN[ERBv2,pre_gate_3_pin]="gpio3"; -PIN[ERB,pre_gate_4_pin]="gpio4"; PIN[ERBv2,pre_gate_4_pin]="gpio4"; -PIN[ERB,pre_gate_5_pin]="gpio5"; PIN[ERBv2,pre_gate_5_pin]="gpio5"; -PIN[ERB,pre_gate_6_pin]="gpio6"; PIN[ERBv2,pre_gate_6_pin]="gpio6"; -PIN[ERB,pre_gate_7_pin]="gpio7"; PIN[ERBv2,pre_gate_7_pin]="gpio7"; -PIN[ERB,pre_gate_8_pin]="gpio26"; PIN[ERBv2,pre_gate_8_pin]="gpio26"; -PIN[ERB,pre_gate_9_pin]="gpio27"; PIN[ERBv2,pre_gate_9_pin]="gpio27"; -PIN[ERB,pre_gate_10_pin]="gpio28"; PIN[ERBv2,pre_gate_10_pin]="gpio28"; -PIN[ERB,pre_gate_11_pin]="gpio29"; PIN[ERBv2,pre_gate_11_pin]="gpio29"; -PIN[ERB,gear_sensor_0_pin]=""; PIN[ERBv2,gear_sensor_0_pin]=""; -PIN[ERB,gear_sensor_1_pin]=""; PIN[ERBv2,gear_sensor_1_pin]=""; -PIN[ERB,gear_sensor_2_pin]=""; PIN[ERBv2,gear_sensor_2_pin]=""; -PIN[ERB,gear_sensor_3_pin]=""; PIN[ERBv2,gear_sensor_3_pin]=""; -PIN[ERB,gear_sensor_4_pin]=""; PIN[ERBv2,gear_sensor_4_pin]=""; -PIN[ERB,gear_sensor_5_pin]=""; PIN[ERBv2,gear_sensor_5_pin]=""; -PIN[ERB,gear_sensor_6_pin]=""; PIN[ERBv2,gear_sensor_6_pin]=""; -PIN[ERB,gear_sensor_7_pin]=""; PIN[ERBv2,gear_sensor_7_pin]=""; -PIN[ERB,gear_sensor_8_pin]=""; PIN[ERBv2,gear_sensor_8_pin]=""; -PIN[ERB,gear_sensor_9_pin]=""; PIN[ERBv2,gear_sensor_9_pin]=""; -PIN[ERB,gear_sensor_10_pin]=""; PIN[ERBv2,gear_sensor_10_pin]=""; -PIN[ERB,gear_sensor_11_pin]=""; PIN[ERBv2,gear_sensor_10_pin]=""; -# -# Common Type-B MMU pin utilization -# -# TODO - - -# Pins for BTT MMB board (gear on motor1, selector on motor2, endstop on STP11, optional gate sensor on STP1 if no gear DIAG use) -# Note BTT MMB v1.1 Board switched M1_enable and STP4 -# -PIN[MMB10,gear_uart_pin]="PA10"; PIN[MMB11,gear_uart_pin]="PA10"; # M1 -PIN[MMB10,gear_step_pin]="PB15"; PIN[MMB11,gear_step_pin]="PB15"; -PIN[MMB10,gear_dir_pin]="PB14"; PIN[MMB11,gear_dir_pin]="PB14"; -PIN[MMB10,gear_enable_pin]="PA8"; PIN[MMB11,gear_enable_pin]="PB8"; -PIN[MMB10,gear_diag_pin]="PA3"; PIN[MMB11,gear_diag_pin]="PA3"; # Aka STP1 -PIN[MMB10,gear_1_uart_pin]=""; PIN[MMB11,gear_1_uart_pin]=""; -PIN[MMB10,gear_1_step_pin]=""; PIN[MMB11,gear_1_step_pin]=""; -PIN[MMB10,gear_1_dir_pin]=""; PIN[MMB11,gear_1_dir_pin]=""; -PIN[MMB10,gear_1_enable_pin]=""; PIN[MMB11,gear_1_enable_pin]=""; -PIN[MMB10,gear_1_diag_pin]=""; PIN[MMB11,gear_1_diag_pin]=""; -PIN[MMB10,gear_2_uart_pin]=""; PIN[MMB11,gear_2_uart_pin]=""; -PIN[MMB10,gear_2_step_pin]=""; PIN[MMB11,gear_2_step_pin]=""; -PIN[MMB10,gear_2_dir_pin]=""; PIN[MMB11,gear_2_dir_pin]=""; -PIN[MMB10,gear_2_enable_pin]=""; PIN[MMB11,gear_2_enable_pin]=""; -PIN[MMB10,gear_2_diag_pin]=""; PIN[MMB11,gear_2_diag_pin]=""; -PIN[MMB10,gear_3_uart_pin]=""; PIN[MMB11,gear_3_uart_pin]=""; -PIN[MMB10,gear_3_step_pin]=""; PIN[MMB11,gear_3_step_pin]=""; -PIN[MMB10,gear_3_dir_pin]=""; PIN[MMB11,gear_3_dir_pin]=""; -PIN[MMB10,gear_3_enable_pin]=""; PIN[MMB11,gear_3_enable_pin]=""; -PIN[MMB10,gear_3_diag_pin]=""; PIN[MMB11,gear_3_diag_pin]=""; -PIN[MMB10,selector_uart_pin]="PC7"; PIN[MMB11,selector_uart_pin]="PC7"; # M2 -PIN[MMB10,selector_step_pin]="PD2"; PIN[MMB11,selector_step_pin]="PD2"; -PIN[MMB10,selector_dir_pin]="PB13"; PIN[MMB11,selector_dir_pin]="PB13"; -PIN[MMB10,selector_enable_pin]="PD1"; PIN[MMB11,selector_enable_pin]="PD1"; -PIN[MMB10,selector_diag_pin]="PA4"; PIN[MMB11,selector_diag_pin]="PA4"; # Aka STP2 -PIN[MMB10,selector_endstop_pin]="PB2"; PIN[MMB11,selector_endstop_pin]="PB2"; # STP11 -PIN[MMB10,selector_servo_pin]="PA0"; PIN[MMB11,selector_servo_pin]="PA0"; -PIN[MMB10,encoder_pin]="PA1"; PIN[MMB11,encoder_pin]="PA1"; -PIN[MMB10,neopixel_pin]="PA2"; PIN[MMB11,neopixel_pin]="PA2"; -PIN[MMB10,gate_sensor_pin]="PA3"; PIN[MMB11,gate_sensor_pin]="PA3"; # STP1 (if not DIAG) -PIN[MMB10,pre_gate_0_pin]="PB9"; PIN[MMB11,pre_gate_0_pin]="PB9"; # STP3 -PIN[MMB10,pre_gate_1_pin]="PB8"; PIN[MMB11,pre_gate_1_pin]="PA8"; # STP4 -PIN[MMB10,pre_gate_2_pin]="PC15"; PIN[MMB11,pre_gate_2_pin]="PC15"; # STP5 -PIN[MMB10,pre_gate_3_pin]="PC13"; PIN[MMB11,pre_gate_3_pin]="PC13"; # STP6 -PIN[MMB10,pre_gate_4_pin]="PC14"; PIN[MMB11,pre_gate_4_pin]="PC14"; # STP7 -PIN[MMB10,pre_gate_5_pin]="PB12"; PIN[MMB11,pre_gate_5_pin]="PB12"; # STP8 -PIN[MMB10,pre_gate_6_pin]="PB11"; PIN[MMB11,pre_gate_6_pin]="PB11"; # STP9 -PIN[MMB10,pre_gate_7_pin]="PB10"; PIN[MMB11,pre_gate_7_pin]="PB10"; # STP10 -PIN[MMB10,pre_gate_8_pin]=""; PIN[MMB11,pre_gate_8_pin]=""; -PIN[MMB10,pre_gate_9_pin]=""; PIN[MMB11,pre_gate_9_pin]=""; -PIN[MMB10,pre_gate_10_pin]=""; PIN[MMB11,pre_gate_10_pin]=""; -PIN[MMB10,pre_gate_11_pin]=""; PIN[MMB11,pre_gate_11_pin]=""; -PIN[MMB10,gear_sensor_0_pin]=""; PIN[MMB11,gear_sensor_0_pin]=""; -PIN[MMB10,gear_sensor_1_pin]=""; PIN[MMB11,gear_sensor_1_pin]=""; -PIN[MMB10,gear_sensor_2_pin]=""; PIN[MMB11,gear_sensor_2_pin]=""; -PIN[MMB10,gear_sensor_3_pin]=""; PIN[MMB11,gear_sensor_3_pin]=""; -PIN[MMB10,gear_sensor_4_pin]=""; PIN[MMB11,gear_sensor_4_pin]=""; -PIN[MMB10,gear_sensor_5_pin]=""; PIN[MMB11,gear_sensor_5_pin]=""; -PIN[MMB10,gear_sensor_6_pin]=""; PIN[MMB11,gear_sensor_6_pin]=""; -PIN[MMB10,gear_sensor_7_pin]=""; PIN[MMB11,gear_sensor_7_pin]=""; -PIN[MMB10,gear_sensor_8_pin]=""; PIN[MMB11,gear_sensor_8_pin]=""; -PIN[MMB10,gear_sensor_9_pin]=""; PIN[MMB11,gear_sensor_9_pin]=""; -PIN[MMB10,gear_sensor_10_pin]=""; PIN[MMB11,gear_sensor_10_pin]=""; -PIN[MMB10,gear_sensor_11_pin]=""; PIN[MMB11,gear_sensor_11_pin]=""; -# -# Common Type-B MMU pin utilization -# -PIN[B,MMB10,gear_uart_pin]="PA10"; PIN[B,MMB11,gear_uart_pin]="PA10"; # M1 -PIN[B,MMB10,gear_step_pin]="PB15"; PIN[B,MMB11,gear_step_pin]="PB15"; -PIN[B,MMB10,gear_dir_pin]="PB14"; PIN[B,MMB11,gear_dir_pin]="PB14"; -PIN[B,MMB10,gear_enable_pin]="PA8"; PIN[B,MMB11,gear_enable_pin]="PB8"; # !Reversed on v1.1 -PIN[B,MMB10,gear_diag_pin]="PA3"; PIN[B,MMB11,gear_diag_pin]="PA3"; # Aka STP1 -PIN[B,MMB10,gear_1_uart_pin]="PC7"; PIN[B,MMB11,gear_1_uart_pin]="PC7"; # M2 -PIN[B,MMB10,gear_1_step_pin]="PD2"; PIN[B,MMB11,gear_1_step_pin]="PD2"; -PIN[B,MMB10,gear_1_dir_pin]="PB13"; PIN[B,MMB11,gear_1_dir_pin]="PB13"; -PIN[B,MMB10,gear_1_enable_pin]="PD1"; PIN[B,MMB11,gear_1_enable_pin]="PD1"; -PIN[B,MMB10,gear_1_diag_pin]="PA4"; PIN[B,MMB11,gear_1_diag_pin]="PA4"; # Aka STP2 -PIN[B,MMB10,gear_2_uart_pin]="PC6"; PIN[B,MMB11,gear_2_uart_pin]="PC6"; # M3 -PIN[B,MMB10,gear_2_step_pin]="PD0"; PIN[B,MMB11,gear_2_step_pin]="PD0"; -PIN[B,MMB10,gear_2_dir_pin]="PD3"; PIN[B,MMB11,gear_2_dir_pin]="PD3"; -PIN[B,MMB10,gear_2_enable_pin]="PA15"; PIN[B,MMB11,gear_2_enable_pin]="PA15"; -PIN[B,MMB10,gear_2_diag_pin]="PB9"; PIN[B,MMB11,gear_2_diag_pin]="PB9"; # Aka STP3 -PIN[B,MMB10,gear_3_uart_pin]="PA9"; PIN[B,MMB11,gear_3_uart_pin]="PA9"; # M4 -PIN[B,MMB10,gear_3_step_pin]="PB6"; PIN[B,MMB11,gear_3_step_pin]="PB6"; -PIN[B,MMB10,gear_3_dir_pin]="PB7"; PIN[B,MMB11,gear_3_dir_pin]="PB7"; -PIN[B,MMB10,gear_3_enable_pin]="PB5"; PIN[B,MMB11,gear_3_enable_pin]="PB5"; -PIN[B,MMB10,gear_3_diag_pin]="PB8"; PIN[B,MMB11,gear_3_diag_pin]="PB8"; # Aka STP4 -PIN[B,MMB10,selector_uart_pin]=""; PIN[B,MMB11,selector_uart_pin]=""; -PIN[B,MMB10,selector_step_pin]=""; PIN[B,MMB11,selector_step_pin]=""; -PIN[B,MMB10,selector_dir_pin]=""; PIN[B,MMB11,selector_dir_pin]=""; -PIN[B,MMB10,selector_enable_pin]=""; PIN[B,MMB11,selector_enable_pin]=""; -PIN[B,MMB10,selector_diag_pin]=""; PIN[B,MMB11,selector_diag_pin]=""; -PIN[B,MMB10,selector_endstop_pin]=""; PIN[B,MMB11,selector_endstop_pin]=""; -PIN[B,MMB10,selector_servo_pin]=""; PIN[B,MMB11,selector_servo_pin]=""; -PIN[B,MMB10,encoder_pin]="PA1"; PIN[B,MMB11,encoder_pin]="PA1"; -PIN[B,MMB10,neopixel_pin]="PA2"; PIN[B,MMB11,neopixel_pin]="PA2"; -PIN[B,MMB10,gate_sensor_pin]="PB11"; PIN[B,MMB11,gate_sensor_pin]="PB11"; # STP9 -PIN[B,MMB10,pre_gate_0_pin]="PA3"; PIN[B,MMB11,pre_gate_0_pin]="PA3"; # STP1 -PIN[B,MMB10,pre_gate_1_pin]="PA4"; PIN[B,MMB11,pre_gate_1_pin]="PA4"; # STP2 -PIN[B,MMB10,pre_gate_2_pin]="PB9"; PIN[B,MMB11,pre_gate_2_pin]="PB9"; # STP3 -PIN[B,MMB10,pre_gate_3_pin]="PB8"; PIN[B,MMB11,pre_gate_3_pin]="PA8"; # STP4 !Reversed on v1.1 -PIN[B,MMB10,pre_gate_4_pin]=""; PIN[B,MMB11,pre_gate_4_pin]=""; -PIN[B,MMB10,pre_gate_5_pin]=""; PIN[B,MMB11,pre_gate_5_pin]=""; -PIN[B,MMB10,pre_gate_6_pin]=""; PIN[B,MMB11,pre_gate_6_pin]=""; -PIN[B,MMB10,pre_gate_7_pin]=""; PIN[B,MMB11,pre_gate_7_pin]=""; -PIN[B,MMB10,pre_gate_8_pin]=""; PIN[B,MMB11,pre_gate_8_pin]=""; -PIN[B,MMB10,pre_gate_9_pin]=""; PIN[B,MMB11,pre_gate_9_pin]=""; -PIN[B,MMB10,pre_gate_10_pin]=""; PIN[B,MMB11,pre_gate_10_pin]=""; -PIN[B,MMB10,pre_gate_11_pin]=""; PIN[B,MMB11,pre_gate_11_pin]=""; -PIN[B,MMB10,gear_sensor_0_pin]="PC15"; PIN[B,MMB11,gear_sensor_0_pin]="PC15"; # STP5 -PIN[B,MMB10,gear_sensor_1_pin]="PC13"; PIN[B,MMB11,gear_sensor_1_pin]="PC13"; # STP6 -PIN[B,MMB10,gear_sensor_2_pin]="PC14"; PIN[B,MMB11,gear_sensor_2_pin]="PC14"; # STP7 -PIN[B,MMB10,gear_sensor_3_pin]="PB12"; PIN[B,MMB11,gear_sensor_3_pin]="PB12"; # STP8 -PIN[B,MMB10,gear_sensor_4_pin]=""; PIN[B,MMB11,gear_sensor_4_pin]=""; -PIN[B,MMB10,gear_sensor_5_pin]=""; PIN[B,MMB11,gear_sensor_5_pin]=""; -PIN[B,MMB10,gear_sensor_6_pin]=""; PIN[B,MMB11,gear_sensor_6_pin]=""; -PIN[B,MMB10,gear_sensor_7_pin]=""; PIN[B,MMB11,gear_sensor_7_pin]=""; -PIN[B,MMB10,gear_sensor_8_pin]=""; PIN[B,MMB11,gear_sensor_8_pin]=""; -PIN[B,MMB10,gear_sensor_9_pin]=""; PIN[B,MMB11,gear_sensor_9_pin]=""; -PIN[B,MMB10,gear_sensor_10_pin]=""; PIN[B,MMB11,gear_sensor_10_pin]=""; -PIN[B,MMB10,gear_sensor_11_pin]=""; PIN[B,MMB11,gear_sensor_11_pin]=""; -PIN[B,MMB10,espooler_en_0_pin]=""; PIN[B,MMB11,espooler_en_0_pin]=""; -PIN[B,MMB10,espooler_en_1_pin]=""; PIN[B,MMB11,espooler_en_1_pin]=""; -PIN[B,MMB10,espooler_en_2_pin]=""; PIN[B,MMB11,espooler_en_2_pin]=""; -PIN[B,MMB10,espooler_en_3_pin]=""; PIN[B,MMB11,espooler_en_3_pin]=""; -PIN[B,MMB10,espooler_rwd_0_pin]="PA0"; PIN[B,MMB11,espooler_rwd_0_pin]="PA0"; -PIN[B,MMB10,espooler_rwd_1_pin]="PA1"; PIN[B,MMB11,espooler_rwd_1_pin]="PA1"; -PIN[B,MMB10,espooler_rwd_2_pin]="PB10"; PIN[B,MMB11,espooler_rwd_2_pin]="PB10"; -PIN[B,MMB10,espooler_rwd_3_pin]="PB2"; PIN[B,MMB11,espooler_rwd_3_pin]="PB2"; -PIN[B,MMB10,espooler_fwd_0_pin]=""; PIN[B,MMB11,espooler_fwd_0_pin]=""; -PIN[B,MMB10,espooler_fwd_1_pin]=""; PIN[B,MMB11,espooler_fwd_1_pin]=""; -PIN[B,MMB10,espooler_fwd_2_pin]=""; PIN[B,MMB11,espooler_fwd_2_pin]=""; -PIN[B,MMB10,espooler_fwd_3_pin]=""; PIN[B,MMB11,espooler_fwd_3_pin]=""; -# Spare: PB10 (STP10), PB2 (STP11) PB10 (STP10), PB2 (STP11) - - -# Type A pins for BTT MMB board v2.0 -# (gear on motor1, selector on motor2, endstop on STP11, optional gate sensor on STP1 if no gear DIAG use) -# -PIN[MMB20,gear_uart_pin]="PB5"; # M1 CS Pin -PIN[MMB20,gear_step_pin]="PD4"; # M1 STEP Pin -PIN[MMB20,gear_dir_pin]="PD3"; # M1 DIR Pin -PIN[MMB20,gear_enable_pin]="PD5"; # M1 EN Pin -PIN[MMB20,gear_diag_pin]="PB8"; # M1 DIAG Pin -PIN[MMB20,gear_1_uart_pin]=""; -PIN[MMB20,gear_1_step_pin]=""; -PIN[MMB20,gear_1_dir_pin]=""; -PIN[MMB20,gear_1_enable_pin]=""; -PIN[MMB20,gear_1_diag_pin]=""; -PIN[MMB20,gear_2_uart_pin]=""; -PIN[MMB20,gear_2_step_pin]=""; -PIN[MMB20,gear_2_dir_pin]=""; -PIN[MMB20,gear_2_enable_pin]=""; -PIN[MMB20,gear_2_diag_pin]=""; -PIN[MMB20,gear_3_uart_pin]=""; -PIN[MMB20,gear_3_step_pin]=""; -PIN[MMB20,gear_3_dir_pin]=""; -PIN[MMB20,gear_3_enable_pin]=""; -PIN[MMB20,gear_3_diag_pin]=""; -PIN[MMB20,selector_uart_pin]="PB4"; # M2 CS Pin -PIN[MMB20,selector_step_pin]="PC9"; # M2 STEP Pin -PIN[MMB20,selector_dir_pin]="PC8"; # M2 DIR Pin -PIN[MMB20,selector_enable_pin]="PD2"; # M2 EN Pin -PIN[MMB20,selector_diag_pin]="PB8"; # M2 DIAG Pin -PIN[MMB20,selector_endstop_pin]="PA15"; # STOP1 -PIN[MMB20,selector_servo_pin]="PA1"; # MOT1 -PIN[MMB20,encoder_pin]="PC2"; # SENSOR -PIN[MMB20,neopixel_pin]="PC3"; # RGB -PIN[MMB20,gate_sensor_pin]=""; # STOP3 (if not DIAG) -PIN[MMB20,pre_gate_0_pin]="PC6"; # STOP5 -PIN[MMB20,pre_gate_1_pin]="PA8"; # STOP6 -PIN[MMB20,pre_gate_2_pin]="PB11"; # STOP7 -PIN[MMB20,pre_gate_3_pin]="PB2"; # STOP8 -PIN[MMB20,pre_gate_4_pin]="PB0"; # STOP9 -PIN[MMB20,pre_gate_5_pin]="PC4"; # STOP10 -PIN[MMB20,pre_gate_6_pin]="PC5"; # STOP11 -PIN[MMB20,pre_gate_7_pin]="PB1"; # STOP12 -PIN[MMB20,pre_gate_8_pin]=""; # STOP13 -PIN[MMB20,pre_gate_9_pin]=""; # STOP14 -PIN[MMB20,pre_gate_10_pin]=""; # STOP15 -PIN[MMB20,pre_gate_11_pin]=""; # STOP16 -PIN[MMB20,gear_sensor_0_pin]=""; -PIN[MMB20,gear_sensor_1_pin]=""; -PIN[MMB20,gear_sensor_2_pin]=""; -PIN[MMB20,gear_sensor_3_pin]=""; -PIN[MMB20,gear_sensor_4_pin]=""; -PIN[MMB20,gear_sensor_5_pin]=""; -PIN[MMB20,gear_sensor_6_pin]=""; -PIN[MMB20,gear_sensor_7_pin]=""; -PIN[MMB20,gear_sensor_8_pin]=""; -PIN[MMB20,gear_sensor_9_pin]=""; -PIN[MMB20,gear_sensor_10_pin]=""; -PIN[MMB20,gear_sensor_11_pin]=""; -# -# Common Type-B MMU pin utilization for BTT MMB v2.0 -# -PIN[B,MMB20,gear_uart_pin]="PB5"; -PIN[B,MMB20,gear_step_pin]="PD4"; -PIN[B,MMB20,gear_dir_pin]="PD3"; -PIN[B,MMB20,gear_enable_pin]="PD5"; -PIN[B,MMB20,gear_diag_pin]="PB9"; -PIN[B,MMB20,gear_1_uart_pin]="PB4"; -PIN[B,MMB20,gear_1_step_pin]="PC9"; -PIN[B,MMB20,gear_1_dir_pin]="PC8"; -PIN[B,MMB20,gear_1_enable_pin]="PD2"; -PIN[B,MMB20,gear_1_diag_pin]="PB8"; -PIN[B,MMB20,gear_2_uart_pin]="PB3"; -PIN[B,MMB20,gear_2_step_pin]="PC15"; -PIN[B,MMB20,gear_2_dir_pin]="PC11"; -PIN[B,MMB20,gear_2_enable_pin]="PC10"; -PIN[B,MMB20,gear_2_diag_pin]="PB7"; -PIN[B,MMB20,gear_3_uart_pin]="PD6"; -PIN[B,MMB20,gear_3_step_pin]="PC13"; -PIN[B,MMB20,gear_3_dir_pin]="PC12"; -PIN[B,MMB20,gear_3_enable_pin]="PC14"; -PIN[B,MMB20,gear_3_diag_pin]="PB6"; -PIN[B,MMB20,selector_uart_pin]=""; -PIN[B,MMB20,selector_step_pin]=""; -PIN[B,MMB20,selector_dir_pin]=""; -PIN[B,MMB20,selector_enable_pin]=""; -PIN[B,MMB20,selector_diag_pin]=""; -PIN[B,MMB20,selector_endstop_pin]=""; -PIN[B,MMB20,selector_servo_pin]=""; -PIN[B,MMB20,encoder_pin]="PC2"; -PIN[B,MMB20,neopixel_pin]="PC3"; -PIN[B,MMB20,gate_sensor_pin]="PA15"; -PIN[B,MMB20,pre_gate_0_pin]="PC7"; -PIN[B,MMB20,pre_gate_1_pin]="PA9"; -PIN[B,MMB20,pre_gate_2_pin]="PB12"; -PIN[B,MMB20,pre_gate_3_pin]="PB10"; -PIN[B,MMB20,pre_gate_4_pin]=""; -PIN[B,MMB20,pre_gate_5_pin]=""; -PIN[B,MMB20,pre_gate_6_pin]=""; -PIN[B,MMB20,pre_gate_7_pin]=""; -PIN[B,MMB20,pre_gate_8_pin]=""; -PIN[B,MMB20,pre_gate_9_pin]=""; -PIN[B,MMB20,pre_gate_10_pin]=""; -PIN[B,MMB20,pre_gate_11_pin]=""; -PIN[B,MMB20,gear_sensor_0_pin]="PC6"; -PIN[B,MMB20,gear_sensor_1_pin]="PA8"; -PIN[B,MMB20,gear_sensor_2_pin]="PB11"; -PIN[B,MMB20,gear_sensor_3_pin]="PB2"; -PIN[B,MMB20,gear_sensor_4_pin]=""; -PIN[B,MMB20,gear_sensor_5_pin]=""; -PIN[B,MMB20,gear_sensor_6_pin]=""; -PIN[B,MMB20,gear_sensor_7_pin]=""; -PIN[B,MMB20,gear_sensor_8_pin]=""; -PIN[B,MMB20,gear_sensor_9_pin]=""; -PIN[B,MMB20,gear_sensor_10_pin]=""; -PIN[B,MMB20,gear_sensor_11_pin]=""; -PIN[B,MMB20,espooler_en_0_pin]=""; -PIN[B,MMB20,espooler_en_1_pin]=""; -PIN[B,MMB20,espooler_en_2_pin]=""; -PIN[B,MMB20,espooler_en_3_pin]=""; -PIN[B,MMB20,espooler_rwd_0_pin]="PB1"; -PIN[B,MMB20,espooler_rwd_1_pin]="PC5"; -PIN[B,MMB20,espooler_rwd_2_pin]="PB0"; -PIN[B,MMB20,espooler_rwd_3_pin]="PC4"; -PIN[B,MMB20,espooler_fwd_0_pin]=""; -PIN[B,MMB20,espooler_fwd_1_pin]=""; -PIN[B,MMB20,espooler_fwd_2_pin]=""; -PIN[B,MMB20,espooler_fwd_3_pin]=""; - - -# Pins for AFC Lite board -# Type-B MMU pin utilization -# -PIN[B,AFC_LITE_1,gear_uart_pin]="PD5"; -PIN[B,AFC_LITE_1,gear_step_pin]="PD4"; -PIN[B,AFC_LITE_1,gear_dir_pin]="PD3"; -PIN[B,AFC_LITE_1,gear_enable_pin]="PD6"; -PIN[B,AFC_LITE_1,gear_diag_pin]="PD2"; -PIN[B,AFC_LITE_1,gear_1_uart_pin]="PD0"; -PIN[B,AFC_LITE_1,gear_1_step_pin]="PC12"; -PIN[B,AFC_LITE_1,gear_1_dir_pin]="PC11"; -PIN[B,AFC_LITE_1,gear_1_enable_pin]="PD1"; -PIN[B,AFC_LITE_1,gear_1_diag_pin]="PC10"; -PIN[B,AFC_LITE_1,gear_2_uart_pin]="PE1"; -PIN[B,AFC_LITE_1,gear_2_step_pin]="PE2"; -PIN[B,AFC_LITE_1,gear_2_dir_pin]="PE3"; -PIN[B,AFC_LITE_1,gear_2_enable_pin]="PE0"; -PIN[B,AFC_LITE_1,gear_2_diag_pin]="PE4"; -PIN[B,AFC_LITE_1,gear_3_uart_pin]="PC6"; -PIN[B,AFC_LITE_1,gear_3_step_pin]="PD15"; -PIN[B,AFC_LITE_1,gear_3_dir_pin]="PD14"; -PIN[B,AFC_LITE_1,gear_3_enable_pin]="PC7"; -PIN[B,AFC_LITE_1,gear_3_diag_pin]="PC8"; -PIN[B,AFC_LITE_1,selector_uart_pin]=""; -PIN[B,AFC_LITE_1,selector_step_pin]=""; -PIN[B,AFC_LITE_1,selector_dir_pin]=""; -PIN[B,AFC_LITE_1,selector_enable_pin]=""; -PIN[B,AFC_LITE_1,selector_diag_pin]=""; -PIN[B,AFC_LITE_1,selector_endstop_pin]=""; -PIN[B,AFC_LITE_1,selector_servo_pin]=""; -PIN[B,AFC_LITE_1,encoder_pin]=""; -PIN[B,AFC_LITE_1,neopixel_pin]="PE14"; -PIN[B,AFC_LITE_1,gate_sensor_pin]="PC4"; -PIN[B,AFC_LITE_1,pre_gate_0_pin]="PC5"; -PIN[B,AFC_LITE_1,pre_gate_1_pin]="PB0"; -PIN[B,AFC_LITE_1,pre_gate_2_pin]="PB1"; -PIN[B,AFC_LITE_1,pre_gate_3_pin]="PB2"; -PIN[B,AFC_LITE_1,pre_gate_4_pin]=""; -PIN[B,AFC_LITE_1,pre_gate_5_pin]=""; -PIN[B,AFC_LITE_1,pre_gate_6_pin]=""; -PIN[B,AFC_LITE_1,pre_gate_7_pin]=""; -PIN[B,AFC_LITE_1,pre_gate_8_pin]=""; -PIN[B,AFC_LITE_1,pre_gate_9_pin]=""; -PIN[B,AFC_LITE_1,pre_gate_10_pin]=""; -PIN[B,AFC_LITE_1,pre_gate_11_pin]=""; -PIN[B,AFC_LITE_1,gear_sensor_0_pin]="PE8"; -PIN[B,AFC_LITE_1,gear_sensor_1_pin]="PE9"; -PIN[B,AFC_LITE_1,gear_sensor_2_pin]="PE10"; -PIN[B,AFC_LITE_1,gear_sensor_3_pin]="PE11"; -PIN[B,AFC_LITE_1,gear_sensor_4_pin]=""; -PIN[B,AFC_LITE_1,gear_sensor_5_pin]=""; -PIN[B,AFC_LITE_1,gear_sensor_6_pin]=""; -PIN[B,AFC_LITE_1,gear_sensor_7_pin]=""; -PIN[B,AFC_LITE_1,gear_sensor_8_pin]=""; -PIN[B,AFC_LITE_1,gear_sensor_9_pin]=""; -PIN[B,AFC_LITE_1,gear_sensor_10_pin]=""; -PIN[B,AFC_LITE_1,gear_sensor_11_pin]=""; -PIN[B,AFC_LITE_1,espooler_en_0_pin]="PA2"; -PIN[B,AFC_LITE_1,espooler_en_1_pin]="PA5"; -PIN[B,AFC_LITE_1,espooler_en_2_pin]="PB13"; -PIN[B,AFC_LITE_1,espooler_en_3_pin]="PD11"; -PIN[B,AFC_LITE_1,espooler_rwd_0_pin]="PA0"; -PIN[B,AFC_LITE_1,espooler_rwd_1_pin]="PA6"; -PIN[B,AFC_LITE_1,espooler_rwd_2_pin]="PB14"; -PIN[B,AFC_LITE_1,espooler_rwd_3_pin]="PD12"; -PIN[B,AFC_LITE_1,espooler_fwd_0_pin]="PA1"; -PIN[B,AFC_LITE_1,espooler_fwd_1_pin]="PA7"; -PIN[B,AFC_LITE_1,espooler_fwd_2_pin]="PB15"; -PIN[B,AFC_LITE_1,espooler_fwd_3_pin]="PD13"; - - -# -# Pins for BTT SKR Pico board -# Type-A MMU pin utilization -# -PIN[SKR_PICO_1,gear_uart_pin]="gpio9"; -PIN[SKR_PICO_1,gear_step_pin]="gpio11"; -PIN[SKR_PICO_1,gear_dir_pin]="gpio10"; -PIN[SKR_PICO_1,gear_enable_pin]="gpio12"; -PIN[SKR_PICO_1,gear_diag_pin]="gpio4"; -PIN[SKR_PICO_1,gear_1_step_pin]=""; -PIN[SKR_PICO_1,gear_1_dir_pin]=""; -PIN[SKR_PICO_1,gear_1_enable_pin]=""; -PIN[SKR_PICO_1,gear_1_diag_pin]=""; -PIN[SKR_PICO_1,gear_2_step_pin]=""; -PIN[SKR_PICO_1,gear_2_dir_pin]=""; -PIN[SKR_PICO_1,gear_2_enable_pin]=""; -PIN[SKR_PICO_1,gear_2_diag_pin]=""; -PIN[SKR_PICO_1,gear_3_step_pin]=""; -PIN[SKR_PICO_1,gear_3_dir_pin]=""; -PIN[SKR_PICO_1,gear_3_enable_pin]=""; -PIN[SKR_PICO_1,gear_3_diag_pin]=""; -PIN[SKR_PICO_1,selector_uart_pin]=""; -PIN[SKR_PICO_1,selector_step_pin]="gpio19"; -PIN[SKR_PICO_1,selector_dir_pin]="gpio28"; -PIN[SKR_PICO_1,selector_enable_pin]="gpio2"; -PIN[SKR_PICO_1,selector_diag_pin]="gpio25"; -PIN[SKR_PICO_1,selector_endstop_pin]=""; -PIN[SKR_PICO_1,selector_servo_pin]="gpio29"; -PIN[SKR_PICO_1,encoder_pin]=""; -PIN[SKR_PICO_1,neopixel_pin]="gpio24"; -PIN[SKR_PICO_1,gate_sensor_pin]=""; -PIN[SKR_PICO_1,pre_gate_0_pin]=""; -PIN[SKR_PICO_1,pre_gate_1_pin]=""; -PIN[SKR_PICO_1,pre_gate_2_pin]=""; -PIN[SKR_PICO_1,pre_gate_3_pin]=""; -PIN[SKR_PICO_1,pre_gate_4_pin]=""; -PIN[SKR_PICO_1,pre_gate_5_pin]=""; -PIN[SKR_PICO_1,pre_gate_6_pin]=""; -PIN[SKR_PICO_1,pre_gate_7_pin]=""; -PIN[SKR_PICO_1,pre_gate_8_pin]=""; -PIN[SKR_PICO_1,pre_gate_9_pin]=""; -PIN[SKR_PICO_1,pre_gate_10_pin]=""; -PIN[SKR_PICO_1,pre_gate_11_pin]=""; -PIN[SKR_PICO_1,gear_sensor_0_pin]=""; -PIN[SKR_PICO_1,gear_sensor_1_pin]=""; -PIN[SKR_PICO_1,gear_sensor_2_pin]=""; -PIN[SKR_PICO_1,gear_sensor_3_pin]=""; -PIN[SKR_PICO_1,gear_sensor_4_pin]=""; -PIN[SKR_PICO_1,gear_sensor_5_pin]=""; -PIN[SKR_PICO_1,gear_sensor_6_pin]=""; -PIN[SKR_PICO_1,gear_sensor_7_pin]=""; -PIN[SKR_PICO_1,gear_sensor_8_pin]=""; -PIN[SKR_PICO_1,gear_sensor_9_pin]=""; -PIN[SKR_PICO_1,gear_sensor_10_pin]=""; -PIN[SKR_PICO_1,gear_sensor_11_pin]=""; -PIN[SKR_PICO_1,espooler_en_0_pin]=""; -PIN[SKR_PICO_1,espooler_en_1_pin]=""; -PIN[SKR_PICO_1,espooler_en_2_pin]=""; -PIN[SKR_PICO_1,espooler_en_3_pin]=""; -PIN[SKR_PICO_1,espooler_rwd_0_pin]=""; -PIN[SKR_PICO_1,espooler_rwd_1_pin]=""; -PIN[SKR_PICO_1,espooler_rwd_2_pin]=""; -PIN[SKR_PICO_1,espooler_rwd_3_pin]=""; -PIN[SKR_PICO_1,espooler_fwd_0_pin]=""; -PIN[SKR_PICO_1,espooler_fwd_1_pin]=""; -PIN[SKR_PICO_1,espooler_fwd_2_pin]=""; -PIN[SKR_PICO_1,espooler_fwd_3_pin]=""; - - - -# -# Pins for BTT SKR Pico board -# Type-B MMU pin utilization -# -PIN[B,SKR_PICO_1,gear_uart_pin]="gpio9"; -PIN[B,SKR_PICO_1,gear_step_pin]="gpio11"; -PIN[B,SKR_PICO_1,gear_dir_pin]="gpio10"; -PIN[B,SKR_PICO_1,gear_enable_pin]="gpio12"; -PIN[B,SKR_PICO_1,gear_diag_pin]="gpio4"; -PIN[B,SKR_PICO_1,gear_1_step_pin]="gpio6"; -PIN[B,SKR_PICO_1,gear_1_dir_pin]="gpio5"; -PIN[B,SKR_PICO_1,gear_1_enable_pin]="gpio7"; -PIN[B,SKR_PICO_1,gear_1_diag_pin]="gpio3"; -PIN[B,SKR_PICO_1,gear_2_step_pin]="gpio19"; -PIN[B,SKR_PICO_1,gear_2_dir_pin]="gpio28"; -PIN[B,SKR_PICO_1,gear_2_enable_pin]="gpio2"; -PIN[B,SKR_PICO_1,gear_2_diag_pin]="gpio25"; -PIN[B,SKR_PICO_1,gear_3_step_pin]="gpio14"; -PIN[B,SKR_PICO_1,gear_3_dir_pin]="gpio13"; -PIN[B,SKR_PICO_1,gear_3_enable_pin]="gpio15"; -PIN[B,SKR_PICO_1,gear_3_diag_pin]="gpio16"; -PIN[B,SKR_PICO_1,selector_uart_pin]=""; -PIN[B,SKR_PICO_1,selector_step_pin]=""; -PIN[B,SKR_PICO_1,selector_dir_pin]=""; -PIN[B,SKR_PICO_1,selector_enable_pin]=""; -PIN[B,SKR_PICO_1,selector_diag_pin]=""; -PIN[B,SKR_PICO_1,selector_endstop_pin]=""; -PIN[B,SKR_PICO_1,selector_servo_pin]=""; -PIN[B,SKR_PICO_1,encoder_pin]=""; -PIN[B,SKR_PICO_1,neopixel_pin]="gpio24"; -PIN[B,SKR_PICO_1,gate_sensor_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_0_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_1_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_2_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_3_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_4_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_5_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_6_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_7_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_8_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_9_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_10_pin]=""; -PIN[B,SKR_PICO_1,pre_gate_11_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_0_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_1_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_2_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_3_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_4_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_5_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_6_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_7_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_8_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_9_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_10_pin]=""; -PIN[B,SKR_PICO_1,gear_sensor_11_pin]=""; -PIN[B,SKR_PICO_1,espooler_en_0_pin]=""; -PIN[B,SKR_PICO_1,espooler_en_1_pin]=""; -PIN[B,SKR_PICO_1,espooler_en_2_pin]=""; -PIN[B,SKR_PICO_1,espooler_en_3_pin]=""; -PIN[B,SKR_PICO_1,espooler_rwd_0_pin]=""; -PIN[B,SKR_PICO_1,espooler_rwd_1_pin]=""; -PIN[B,SKR_PICO_1,espooler_rwd_2_pin]=""; -PIN[B,SKR_PICO_1,espooler_rwd_3_pin]=""; -PIN[B,SKR_PICO_1,espooler_fwd_0_pin]=""; -PIN[B,SKR_PICO_1,espooler_fwd_1_pin]=""; -PIN[B,SKR_PICO_1,espooler_fwd_2_pin]=""; -PIN[B,SKR_PICO_1,espooler_fwd_3_pin]=""; - - - -# -# Pins for BTT EBB 42 CANbus v1.2 -# Type-A MMU pin utilization used on MMX and PicoMMU -# -PIN[EBB42_12,gear_uart_pin]="PA15"; -PIN[EBB42_12,gear_step_pin]="PD0"; -PIN[EBB42_12,gear_dir_pin]="PD1"; -PIN[EBB42_12,gear_enable_pin]="PD2"; -PIN[EBB42_12,gear_diag_pin]=""; -PIN[EBB42_12,gear_1_uart_pin]=""; -PIN[EBB42_12,gear_1_step_pin]=""; -PIN[EBB42_12,gear_1_dir_pin]=""; -PIN[EBB42_12,gear_1_enable_pin]=""; -PIN[EBB42_12,gear_1_diag_pin]=""; -PIN[EBB42_12,gear_2_uart_pin]=""; -PIN[EBB42_12,gear_2_step_pin]=""; -PIN[EBB42_12,gear_2_dir_pin]=""; -PIN[EBB42_12,gear_2_enable_pin]=""; -PIN[EBB42_12,gear_2_diag_pin]=""; -PIN[EBB42_12,gear_3_uart_pin]=""; -PIN[EBB42_12,gear_3_step_pin]=""; -PIN[EBB42_12,gear_3_dir_pin]=""; -PIN[EBB42_12,gear_3_enable_pin]=""; -PIN[EBB42_12,gear_3_diag_pin]=""; -PIN[EBB42_12,selector_uart_pin]=""; -PIN[EBB42_12,selector_step_pin]=""; -PIN[EBB42_12,selector_dir_pin]=""; -PIN[EBB42_12,selector_enable_pin]=""; -PIN[EBB42_12,selector_diag_pin]=""; -PIN[EBB42_12,selector_endstop_pin]=""; -PIN[EBB42_12,selector_servo_pin]="PB9"; -PIN[EBB42_12,encoder_pin]=""; -PIN[EBB42_12,neopixel_pin]="PD3"; -PIN[EBB42_12,gate_sensor_pin]="PB4"; -PIN[EBB42_12,pre_gate_0_pin]="PB7"; -PIN[EBB42_12,pre_gate_1_pin]="PB5"; -PIN[EBB42_12,pre_gate_2_pin]="PB6"; -PIN[EBB42_12,pre_gate_3_pin]="PB8"; -PIN[EBB42_12,pre_gate_4_pin]=""; -PIN[EBB42_12,pre_gate_5_pin]=""; -PIN[EBB42_12,pre_gate_6_pin]=""; -PIN[EBB42_12,pre_gate_7_pin]=""; -PIN[EBB42_12,pre_gate_8_pin]=""; -PIN[EBB42_12,pre_gate_9_pin]=""; -PIN[EBB42_12,pre_gate_10_pin]=""; -PIN[EBB42_12,pre_gate_11_pin]=""; -PIN[EBB42_12,gear_sensor_0_pin]=""; -PIN[EBB42_12,gear_sensor_1_pin]=""; -PIN[EBB42_12,gear_sensor_2_pin]=""; -PIN[EBB42_12,gear_sensor_3_pin]=""; -PIN[EBB42_12,gear_sensor_4_pin]=""; -PIN[EBB42_12,gear_sensor_5_pin]=""; -PIN[EBB42_12,gear_sensor_6_pin]=""; -PIN[EBB42_12,gear_sensor_7_pin]=""; -PIN[EBB42_12,gear_sensor_8_pin]=""; -PIN[EBB42_12,gear_sensor_9_pin]=""; -PIN[EBB42_12,gear_sensor_10_pin]=""; -PIN[EBB42_12,gear_sensor_11_pin]=""; -PIN[EBB42_12,espooler_en_0_pin]=""; -PIN[EBB42_12,espooler_en_1_pin]=""; -PIN[EBB42_12,espooler_en_2_pin]=""; -PIN[EBB42_12,espooler_en_3_pin]=""; -PIN[EBB42_12,espooler_rwd_0_pin]=""; -PIN[EBB42_12,espooler_rwd_1_pin]=""; -PIN[EBB42_12,espooler_rwd_2_pin]=""; -PIN[EBB42_12,espooler_rwd_3_pin]=""; -PIN[EBB42_12,espooler_fwd_0_pin]=""; -PIN[EBB42_12,espooler_fwd_1_pin]=""; -PIN[EBB42_12,espooler_fwd_2_pin]=""; -PIN[EBB42_12,espooler_fwd_3_pin]=""; - - - -# Pins for WGB board (AFC or other type-B designs) -# Type-B MMU pin utilization -# -PIN[B,WGB_3,gear_uart_pin]="PC12"; -PIN[B,WGB_3,gear_step_pin]="PD4"; -PIN[B,WGB_3,gear_dir_pin]="PD3"; -PIN[B,WGB_3,gear_enable_pin]="PD5"; -PIN[B,WGB_3,gear_diag_pin]="PD6"; -PIN[B,WGB_3,gear_1_uart_pin]="PC10"; -PIN[B,WGB_3,gear_1_step_pin]="PD0"; -PIN[B,WGB_3,gear_1_dir_pin]="PC11"; -PIN[B,WGB_3,gear_1_enable_pin]="PD1"; -PIN[B,WGB_3,gear_1_diag_pin]="PD2"; -PIN[B,WGB_3,gear_2_uart_pin]="PA9"; -PIN[B,WGB_3,gear_2_step_pin]="PC8"; -PIN[B,WGB_3,gear_2_dir_pin]="PA10"; -PIN[B,WGB_3,gear_2_enable_pin]="PC9"; -PIN[B,WGB_3,gear_2_diag_pin]="PA8"; -PIN[B,WGB_3,gear_3_uart_pin]="PC6"; -PIN[B,WGB_3,gear_3_step_pin]="PD13"; -PIN[B,WGB_3,gear_3_dir_pin]="PD12"; -PIN[B,WGB_3,gear_3_enable_pin]="PD14"; -PIN[B,WGB_3,gear_3_diag_pin]="PD15"; -PIN[B,WGB_3,selector_uart_pin]=""; -PIN[B,WGB_3,selector_step_pin]=""; -PIN[B,WGB_3,selector_dir_pin]=""; -PIN[B,WGB_3,selector_enable_pin]=""; -PIN[B,WGB_3,selector_diag_pin]=""; -PIN[B,WGB_3,selector_endstop_pin]=""; -PIN[B,WGB_3,selector_servo_pin]=""; -PIN[B,WGB_3,encoder_pin]=""; -PIN[B,WGB_3,neopixel_pin]="PB15"; -PIN[B,WGB_3,gate_sensor_pin]="PE7"; -PIN[B,WGB_3,pre_gate_0_pin]="PA1"; -PIN[B,WGB_3,pre_gate_1_pin]="PA2"; -PIN[B,WGB_3,pre_gate_2_pin]="PA3"; -PIN[B,WGB_3,pre_gate_3_pin]="PA4"; -PIN[B,WGB_3,pre_gate_4_pin]=""; -PIN[B,WGB_3,pre_gate_5_pin]=""; -PIN[B,WGB_3,pre_gate_6_pin]=""; -PIN[B,WGB_3,pre_gate_7_pin]=""; -PIN[B,WGB_3,pre_gate_8_pin]=""; -PIN[B,WGB_3,pre_gate_9_pin]=""; -PIN[B,WGB_3,pre_gate_10_pin]=""; -PIN[B,WGB_3,pre_gate_11_pin]=""; -PIN[B,WGB_3,gear_sensor_0_pin]="PA5"; -PIN[B,WGB_3,gear_sensor_1_pin]="PA6"; -PIN[B,WGB_3,gear_sensor_2_pin]="PA7"; -PIN[B,WGB_3,gear_sensor_3_pin]="PC4"; -PIN[B,WGB_3,gear_sensor_4_pin]=""; -PIN[B,WGB_3,gear_sensor_5_pin]=""; -PIN[B,WGB_3,gear_sensor_6_pin]=""; -PIN[B,WGB_3,gear_sensor_7_pin]=""; -PIN[B,WGB_3,gear_sensor_8_pin]=""; -PIN[B,WGB_3,gear_sensor_9_pin]=""; -PIN[B,WGB_3,gear_sensor_10_pin]=""; -PIN[B,WGB_3,gear_sensor_11_pin]=""; -PIN[B,WGB_3,espooler_en_0_pin]="PD11"; -PIN[B,WGB_3,espooler_en_1_pin]="PD10"; -PIN[B,WGB_3,espooler_en_2_pin]="PD9"; -PIN[B,WGB_3,espooler_en_3_pin]="PD8"; -PIN[B,WGB_3,espooler_rwd_0_pin]="PE15"; -PIN[B,WGB_3,espooler_rwd_1_pin]="PE13"; -PIN[B,WGB_3,espooler_rwd_2_pin]="PE11"; -PIN[B,WGB_3,espooler_rwd_3_pin]="PE8"; -PIN[B,WGB_3,espooler_fwd_0_pin]="PB10"; -PIN[B,WGB_3,espooler_fwd_1_pin]="PE14"; -PIN[B,WGB_3,espooler_fwd_2_pin]="PE12"; -PIN[B,WGB_3,espooler_fwd_3_pin]="PE10"; - - - -# Pins for TZB V1 board -# Type-A MMU pin utilization -# -PIN[TZB_1,gear_uart_pin]="PD1"; -PIN[TZB_1,gear_step_pin]="PD0"; -PIN[TZB_1,gear_dir_pin]="PA15"; -PIN[TZB_1,gear_enable_pin]="PD3"; -PIN[TZB_1,gear_diag_pin]="PD2"; -PIN[TZB_1,gear_1_uart_pin]=""; -PIN[TZB_1,gear_1_step_pin]=""; -PIN[TZB_1,gear_1_dir_pin]=""; -PIN[TZB_1,gear_1_enable_pin]=""; -PIN[TZB_1,gear_1_diag_pin]=""; -PIN[TZB_1,gear_2_uart_pin]=""; -PIN[TZB_1,gear_2_step_pin]=""; -PIN[TZB_1,gear_2_dir_pin]=""; -PIN[TZB_1,gear_2_enable_pin]=""; -PIN[TZB_1,gear_2_diag_pin]=""; -PIN[TZB_1,gear_3_uart_pin]=""; -PIN[TZB_1,gear_3_step_pin]=""; -PIN[TZB_1,gear_3_dir_pin]=""; -PIN[TZB_1,gear_3_enable_pin]=""; -PIN[TZB_1,gear_3_diag_pin]=""; -PIN[TZB_1,selector_uart_pin]="PB5"; -PIN[TZB_1,selector_step_pin]="PB4"; -PIN[TZB_1,selector_dir_pin]="PB3"; -PIN[TZB_1,selector_enable_pin]="PB7"; -PIN[TZB_1,selector_diag_pin]="PB6"; -PIN[TZB_1,selector_endstop_pin]="PA4"; -PIN[TZB_1,selector_servo_pin]="PA0"; -PIN[TZB_1,encoder_pin]="PA2"; -PIN[TZB_1,neopixel_pin]="PA5"; -PIN[TZB_1,gate_sensor_pin]=""; -PIN[TZB_1,pre_gate_0_pin]="PB14"; -PIN[TZB_1,pre_gate_1_pin]="PB13"; -PIN[TZB_1,pre_gate_2_pin]="PB12"; -PIN[TZB_1,pre_gate_3_pin]="PA7"; -PIN[TZB_1,pre_gate_4_pin]="PA6"; -PIN[TZB_1,pre_gate_5_pin]="PB2"; -PIN[TZB_1,pre_gate_6_pin]="PA13"; -PIN[TZB_1,pre_gate_7_pin]="PA10"; -PIN[TZB_1,pre_gate_8_pin]=""; -PIN[TZB_1,pre_gate_9_pin]=""; -PIN[TZB_1,pre_gate_10_pin]=""; -PIN[TZB_1,pre_gate_11_pin]=""; -PIN[TZB_1,gear_sensor_0_pin]=""; -PIN[TZB_1,gear_sensor_1_pin]=""; -PIN[TZB_1,gear_sensor_2_pin]=""; -PIN[TZB_1,gear_sensor_3_pin]=""; -PIN[TZB_1,gear_sensor_4_pin]=""; -PIN[TZB_1,gear_sensor_5_pin]=""; -PIN[TZB_1,gear_sensor_6_pin]=""; -PIN[TZB_1,gear_sensor_7_pin]=""; -PIN[TZB_1,gear_sensor_8_pin]=""; -PIN[TZB_1,gear_sensor_9_pin]=""; -PIN[TZB_1,gear_sensor_10_pin]=""; -PIN[TZB_1,gear_sensor_11_pin]=""; diff --git a/klippy/extras/Happy-Hare/test/__init__.py b/klippy/extras/Happy-Hare/test/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/klippy/extras/Happy-Hare/test/components/__init__.py b/klippy/extras/Happy-Hare/test/components/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/klippy/extras/Happy-Hare/test/components/test_mmu_server.py b/klippy/extras/Happy-Hare/test/components/test_mmu_server.py deleted file mode 100644 index cd7ae297d273..000000000000 --- a/klippy/extras/Happy-Hare/test/components/test_mmu_server.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -import shutil -import unittest -from unittest.mock import MagicMock - -from components.mmu_server import MmuServer - -class TestMmuServerFileProcessor(unittest.TestCase): - TOOLCHANGE_FILEPATH = 'test/support/toolchange.gcode' - NO_TOOLCHANGE_FILEPATH = 'test/support/no_toolchange.gcode' - - def setUp(self): - self.subject = MmuServer(MagicMock()) - shutil.copyfile('test/support/toolchange.orig.gcode', self.TOOLCHANGE_FILEPATH) - shutil.copyfile('test/support/no_toolchange.orig.gcode', self.NO_TOOLCHANGE_FILEPATH) - - def tearDown(self): - os.remove(self.TOOLCHANGE_FILEPATH) - os.remove(self.NO_TOOLCHANGE_FILEPATH) - - def test_filelist_callback_when_enabled(self): - self.subject.enable_file_preprocessor = True - self.subject._write_mmu_metadata = MagicMock() - - self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.gcode'}}) - - self.subject._write_mmu_metadata.assert_called_once() - - def test_filelist_callback_when_disabled(self): - self.subject.enable_file_preprocessor = False - self.subject._write_mmu_metadata = MagicMock() - - self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.gcode'}}) - - self.subject._write_mmu_metadata.assert_not_called() - - def test_filelist_callback_when_wrong_event(self): - self.subject.enable_file_preprocessor = False - self.subject._write_mmu_metadata = MagicMock() - - self.subject._filelist_changed({'action': 'move_file', 'item': {'path': 'test.gcode'}}) - - self.subject._write_mmu_metadata.assert_not_called() - - def test_filelist_callback_when_wrong_file_type(self): - self.subject.enable_file_preprocessor = False - self.subject._write_mmu_metadata = MagicMock() - - self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.txt'}}) - - self.subject._write_mmu_metadata.assert_not_called() - - def test_write_mmu_metadata_when_writing_to_files(self): - self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) - - with open(self.TOOLCHANGE_FILEPATH, 'r') as f: - file_contents = f.read() - self.assertIn('PRINT_START MMU_TOOLS_USED=0,1,3,4,5,12\n', file_contents) - - def test_write_mmu_metadata_when_no_toolchanges(self): - self.subject._write_mmu_metadata(self.NO_TOOLCHANGE_FILEPATH) - - with open(self.NO_TOOLCHANGE_FILEPATH, 'r') as f: - file_contents = f.read() - self.assertIn('PRINT_START MMU_TOOLS_USED=\n', file_contents) - - def test_write_mmu_metadata_does_not_replace_comments(self): - self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) - - with open(self.TOOLCHANGE_FILEPATH, 'r') as f: - file_contents = f.read() - self.assertIn('; start_gcode: PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools!', file_contents) - - def test_inject_tool_usage_called_if_placeholder(self): - self.subject._inject_tool_usage = MagicMock() - - self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) - - self.subject._inject_tool_usage.assert_called() - - def test_inject_tool_usage_not_called_if_no_placeholder(self): - # Call it once to remove the placeholder - self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) - self.subject._inject_tool_usage = MagicMock() - - self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) - - self.subject._inject_tool_usage.assert_not_called() diff --git a/klippy/extras/Happy-Hare/test/runner.sh b/klippy/extras/Happy-Hare/test/runner.sh deleted file mode 100755 index 5dcd32983583..000000000000 --- a/klippy/extras/Happy-Hare/test/runner.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# Happy Hare MMU Software -# Test runner -# -# Copyright (C) 2023 Kieran Eglin <@kierantheman (discord)>, -# -# NOTE: in order for tests to get picked up automatically, you must do the following: -# 1. Create a file in the test directory with the name test_*.py -# 2. Create a class in that file that inherits from unittest.TestCase -# 3. Ensure that each test directory has a blank file named `__init__.py` - -python3 -m unittest diff --git a/klippy/extras/Happy-Hare/test/support/no_toolchange.orig.gcode b/klippy/extras/Happy-Hare/test/support/no_toolchange.orig.gcode deleted file mode 100644 index 87b03d9e4c31..000000000000 --- a/klippy/extras/Happy-Hare/test/support/no_toolchange.orig.gcode +++ /dev/null @@ -1,21 +0,0 @@ -PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! -G1 F1200 -G1 X167.759 Y180.16 E.00802 -G1 X167.263 Y180.262 E.02305 -G1 X166.979 Y180.193 E.0133 -G1 X166.433 Y179.911 E.02797 -G1 X165.996 Y179.509 E.02703 -G1 X165.321 Y178.61 E.05117 -G1 X164.865 Y178.289 E.02538 -G1 X164.172 Y177.944 E.03524 -G1 X163.551 Y177.578 E.03281 -G1 X162.963 Y177.124 E.03382 -G1 X162.489 Y176.633 E.03107 -G1 X162.239 Y176.218 E.02205 -G1 X162.031 Y175.724 E.0244 -G1 X162.025 Y174.997 E.03309 -G1 X162.042 Y174.657 E.0155 -G1 X162.144 Y174.141 E.02394 -G1 X162.313 Y174.145 E.0077 -G1 X162.633 Y174.049 E.01521 -G1 X162.964 Y174.006 E.01519 diff --git a/klippy/extras/Happy-Hare/test/support/toolchange.orig.gcode b/klippy/extras/Happy-Hare/test/support/toolchange.orig.gcode deleted file mode 100644 index 0e822ae6eddf..000000000000 --- a/klippy/extras/Happy-Hare/test/support/toolchange.orig.gcode +++ /dev/null @@ -1,33 +0,0 @@ -PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! -T0 -G1 F1200 -G1 X167.759 Y180.16 E.00802 -G1 X167.263 Y180.262 E.02305 -G1 X166.979 Y180.193 E.0133 -T1 -G1 X166.433 Y179.911 E.02797 -G1 X165.996 Y179.509 E.02703 -G1 X165.321 Y178.61 E.05117 -G1 X164.865 Y178.289 E.02538 -G1 X164.172 Y177.944 E.03524 -G1 X163.551 Y177.578 E.03281 - -; T7 -; The above shouldn't count -G1 X162.963 Y177.124 E.03382 -G1 X162.489 Y176.633 E.03107 -T12 ; Testing 2-digit numbers -G1 X162.239 Y176.218 E.02205 -G1 X162.031 Y175.724 E.0244 -MMU_CHANGE_TOOL TOOL=3 -MMU_CHANGE_TOOL FOO=bar TOOL=4 BAZ=quz -G1 X162.025 Y174.997 E.03309 -G1 X162.042 Y174.657 E.0155 -MMU_CHANGE_TOOL_STANDALONE TOOL=5 -G1 X162.144 Y174.141 E.02394 -SHOULDNT_COUNT ARG=T7 -G1 X162.313 Y174.145 E.0077 -G1 X162.633 Y174.049 E.01521 -G1 X162.964 Y174.006 E.01519 -; simulating slicer metadata below (should not be replaced) -; start_gcode: PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! diff --git a/klippy/extras/Happy-Hare/utils/plot_sync_feedback.sh b/klippy/extras/Happy-Hare/utils/plot_sync_feedback.sh deleted file mode 100755 index 1ad59c39abd2..000000000000 --- a/klippy/extras/Happy-Hare/utils/plot_sync_feedback.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Simple wrapper to source klipper python env and run sync_feedback.py with the proper PYTHONPATH - -# ----- Argument validation ----- -[ "$#" -gt 1 ] && { - echo "Usage: $0 []" - exit 1 -} - -log="${1:-`ls -1 ~/printer_data/logs/sync_?.jsonl 2>/dev/null`}" - -if ! [ -e "$log" ]; then - echo $log Flowguard telemetry file doesn\'t exist - exit 1 -fi - -echo Processing ${log} Flowguard telemetry file - -source ~/klippy-env/bin/activate -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MMU_DIR="${SCRIPT_DIR}/../extras/mmu" -export PYTHONPATH="${MMU_DIR}:${PYTHONPATH}" - -python "${SCRIPT_DIR}/sync_feedback_sim.py" --plot "$log" diff --git a/klippy/extras/Happy-Hare/utils/sim_sync_feedback.sh b/klippy/extras/Happy-Hare/utils/sim_sync_feedback.sh deleted file mode 100755 index 34a0499c8da0..000000000000 --- a/klippy/extras/Happy-Hare/utils/sim_sync_feedback.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# Simple wrapper to run sync_feedback.py with the proper PYTHONPATH -# -# Simulation cmd line options: -# --sensor-type=[P|D|CO|TO] -# --buffer-range-mm= (default=8.0) -# --buffer-max-range-mm (default=12.0) -# --initial-sensor=[random|neutral] -# --stride-mm=10 (normal extruder movement between updates) -# --tick-dt-s (default dt used only for manual 'tick', 'clog' and 'tangle', default: 1.0) -# --rd-start (starting extruder rotation distance, default: 20.0) -# --sensor-lag-mm (lag in sensor reacting to movement, default: 0) -# --chaos=2 (simulates friction and jerky movements, multiple of buffer_max_range) -# --sample-error=0.25 (simulates "late" updates from extruder movement Eg 0.25 = 100%-125% of stride) -# --switch-hysteresis=0.2 (factor based on buffer_range) -# --use-twolevel (forces P type sensors to operation in twolevel mode instead of EKF default) -# --log-debug (display debug trace log entries) -# --out= (output PNG filename for plots, default: sim_plot.png) -# --log= (simulator json log output, default: sim.jsonl) -# Use --chaos=0 sample-error=0 for "pure" simulation -# -# E.g. realistic type-P proportional sensor simulation: -# ./sim_sync_feedback.sh --sensor-type P --initial-sensor=random --stride-mm=2.5 --chaos=2 --sample-error=0.5 --sensor-lag=0 -# -# E.g. realistic type-CO switch sensor simulation: -# ./sim_sync_feedback.sh --sensor-type CO --initial-sensor=random --stride-mm=2.5 --chaos=2 --sample-error=0.5 --sensor-lag=0 --switch-hysteresis=0.2 - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MMU_DIR="${SCRIPT_DIR}/../extras/mmu" -export PYTHONPATH="${MMU_DIR}:${PYTHONPATH}" - -python "${SCRIPT_DIR}/sync_feedback_sim.py" "$@" - diff --git a/klippy/extras/Happy-Hare/utils/sync_feedback_sim.py b/klippy/extras/Happy-Hare/utils/sync_feedback_sim.py deleted file mode 100644 index 7d9d065f24f6..000000000000 --- a/klippy/extras/Happy-Hare/utils/sync_feedback_sim.py +++ /dev/null @@ -1,2027 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Happy Hare MMU Software -# Simulator/CLI for the (movement-based) filament tension controller -# -# Simulator invocation: -# python -m utils.sync_feedback.py -# --sensor-type=[P|D|CO|TO] -# --buffer-range-mm= (default=8.0) -# --buffer-max-range-mm (default=12.0) -# --initial-sensor=[random|neutral] -# --stride-mm=10 (normal extruder movement between updates) -# --tick-dt-s (default dt used only for manual 'tick', 'clog' and 'tangle', default: 1.0) -# --rd-start (starting extruder rotation distance, default: 20.0) -# --sensor-lag-mm (lag in sensor reacting to movement, default: 0) -# --chaos=2 (simulates friction and jerky movements, multiple of buffer_max_range) -# --sample-error=0.25 (simulates "late" updates from extruder movement Eg 0.25 = 100%-125% of stride) -# --switch-hysteresis=0.2 (factor based on buffer_range) -# --use-twolevel (forces P type sensors to operation in twolevel mode instead of EKF default) -# --log-debug (display debug trace log entries) -# --out= (output PNG filename for plots, default: sim_plot.png) -# --log= (simulator json log output, default: sim.jsonl) -# Use --chaos=0 sample-error=0 for "pure" simulation -# -# Grpahing logs: -# python sync_feedback.py --plot= -# -# -# Requires: mmu_sync_feedback_manager.py (SyncControllerConfig, SyncController) -# -# Copyright (C) 2022-2026 moggieuk#6538 (discord) -# moggieuk@hotmail.com -# -# (\_/) -# ( *,*) -# (")_(") Happy Hare Ready -# -# This file may be distributed under the terms of the GNU GPLv3 license. - -from __future__ import annotations -import argparse -import json -import math -import os -import time -import random -from typing import Any, Dict, List, Optional, Tuple - -import matplotlib.pyplot as plt -import numpy as np - -from mmu_sync_controller import SyncControllerConfig, SyncController -#try: -# from mmu_sync_controller import SyncControllerConfig, SyncController -#except Exception as e: -# print("Could not load SyncConfig module. Simulation not possible.") - -# ---------- Optional readline for command history (Up/Down arrows) ---------- -HAVE_READLINE = False -try: - import readline # POSIX / macOS - HAVE_READLINE = True -except Exception: - try: - import pyreadline as readline # legacy Windows - HAVE_READLINE = True - except Exception: - readline = None # plain input() fallback - -def _setup_readline(history_limit: int = 500): - if not HAVE_READLINE: - return - try: - readline.set_history_length(history_limit) - try: - readline.parse_and_bind("tab: complete") - except Exception: - pass - except Exception: - pass - -def _add_history(line: str): - if not HAVE_READLINE: - return - try: - n = readline.get_current_history_length() - if n == 0 or readline.get_history_item(n) != line: - readline.add_history(line) - except Exception: - pass - -# ------------------------------ JSON Logger ----------------------------- - -class SimLogger: - """Append-only JSONL logger that clears on startup; tick starts at 0.""" - def __init__(self, path: str = "sim.jsonl", truncate_on_init: bool = True): - self.path = os.path.abspath(path) - self.records_in_session: List[Dict[str, Any]] = [] - if truncate_on_init: - self.clear() - else: - self.tick = self._load_last_tick_plus_one() - - def clear(self): - with open(self.path, "w", encoding="utf-8"): - pass - self.tick = 0 - self.records_in_session.clear() - - def _load_last_tick_plus_one(self) -> int: - last_tick = -1 - if os.path.exists(self.path): - try: - with open(self.path, "r", encoding="utf-8") as f: - for line in f: - try: - rec = json.loads(line) - if isinstance(rec, dict): - t = rec.get("meta", {}).get("log_tick", None) - if t is not None: - last_tick = max(last_tick, int(t)) - except Exception: - continue - except Exception: - pass - return last_tick + 1 - - def append(self, record: Dict[str, Any]) -> Dict[str, Any]: - rec = dict(record) - rec.setdefault("meta", {}) - rec["meta"]["log_tick"] = self.tick - with open(self.path, "a", encoding="utf-8") as f: - f.write(json.dumps(rec) + "\n") - self.records_in_session.append(rec) - self.tick += 1 - return rec - - def write_header(self, header: Dict[str, Any]) -> None: - try: - with open(self.path, "a", encoding="utf-8") as f: - f.write(json.dumps({"header": header}) + "\n") - except Exception: - pass - - def load_all(self) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - if not os.path.exists(self.path): - return out - with open(self.path, "r", encoding="utf-8") as f: - for line in f: - try: - out.append(json.loads(line)) - except Exception: - continue - return out - -# -------------------------- Printer (physical model) -------------------- - -class SimplePrinterModel: - """ - Printer model (normalized spring x_true; spring_mm = x_true * (buffer_range_mm/2)): - - x_true[k+1] = x_true[k] + (2 / buffer_range_mm) * Δ_rel_true - - where the *relative* motion between the filament and the gear during an - extruder command d_ext is: - - Δ_rel_true = d_ext * (extruder_rd_true / rd_prev - 1.0) - - Notes: - • If rd_true == rd_prev → Δ_rel_true = 0 (spring stays flat). - • If rd_true > rd_prev → (ratio - 1) > 0: - - d_ext > 0 (extrude) → compression (+x / +spring) - - d_ext < 0 (retract) → tension (−x / −spring) - • rd_prev must be the RD in effect *for this step* (before it changes). - """ - def __init__( - self, - controller: SyncController, - extruder_rd_true: Optional[float] = None, - initial_spring_mm: float = 0.0, - chaos: float = 0.0, - hysteresis: float = 0.0, - ): - self.ctrl = controller - self.buffer_range_mm = controller.cfg.buffer_range_mm - self.buffer_max_range_mm = controller.cfg.buffer_max_range_mm - - # Normalization factors / limits - self.K = 2.0 / self.buffer_range_mm # mm_rel -> normalized x - self._norm_clip = max(1e-9, self.buffer_max_range_mm / self.buffer_range_mm) - - # State: true normalized spring position and measured proxy - x0 = (2.0 * initial_spring_mm) / self.buffer_range_mm - self.x_true = max(-self._norm_clip, min(self._norm_clip, x0)) - rd_ref = controller.cfg.rd_start - self.extruder_rd_true = extruder_rd_true if extruder_rd_true is not None else rd_ref - - self.chaos = max(0.0, min(2.0, float(chaos))) - self.x_meas = self.x_true - - # Hysteresis fraction for switch sensors (0..0.4 typical) - self.hysteresis = max(0.0, min(0.4, float(hysteresis))) - - # Last digital switch state (for D/CO/TO). Initialize from current x_meas. - self._last_switch_value: int = 0 - self._bootstrap_switch_state() - - # Monotonic simulation clock (seconds since start). Commands must use/advance this. - self.time_s: float = 0.0 - - # ---------- Small physics helpers (new) ---------- - def ratio_true_over_prev(self, rd_prev: float) -> float: - """Return RD_true / rd_prev with a tiny floor to avoid division by ~0.""" - return self.extruder_rd_true / max(1e-9, float(rd_prev)) - - def delta_rel(self, rd_prev: float, d_ext_mm: float) -> float: - """ - Relative filament-vs-gear motion (mm) for this chunk, using the *current* hardware RD - and the controller RD *in effect* during this chunk (rd_prev). - """ - ratio = self.ratio_true_over_prev(rd_prev) # RD_true / RD_prev - return float(d_ext_mm) * (ratio - 1.0) - - def gain_per_mm(self, rd_in_effect: float) -> float: - """ - Normalized spring change per +1 mm of extruder motion for the *current* tick. - g = (2 / buffer_range_mm) * (rd_true / rd_in_effect - 1.0) - Positive g means +x (compression) for +d_ext when rd_true > rd_in_effect. - """ - ratio = self.extruder_rd_true / max(1e-9, float(rd_in_effect)) - return (2.0 / self.buffer_range_mm) * (ratio - 1.0) - - def advance_physics(self, rd_in_effect: float, d_ext_mm: float) -> None: - """ - Advance the physical spring using the RD that was in effect during this motion. - Δx = g * d_ext_mm (with g from gain_per_mm) - """ - self.x_true += self.gain_per_mm(rd_in_effect) * float(d_ext_mm) - self.x_true = min(max(self.x_true, -self._norm_clip), self._norm_clip) # clip to physical limits - - def apply_motion(self, d_ext: float, rd_used: float) -> float: - """ - Advance the physical spring for a single commanded extruder motion (in mm), - using the rotation distance that was actually in effect for that motion. - - Physics (normalized spring x_true; spring_mm = x_true * (buffer_range_mm/2)): - gear_mm = (extruder_rd_true / rd_used) * d_ext - Δ_rel = gear_mm - d_ext # positive = compression - x_true += (2/BR) * Δ_rel - - Returns the Δ_rel (mm) applied this step (useful for debugging). - """ - rd_used = max(1e-9, float(rd_used)) - ratio = self.extruder_rd_true / rd_used - delta_rel = (ratio * d_ext) - d_ext # = d_ext * (ratio - 1) - self.x_true += self.K * delta_rel - self.x_true = min(max(self.x_true, -self._norm_clip), self._norm_clip) - return delta_rel - - # ---------- Basic properties ---------- - def set_extruder_rd_true(self, rd_true: float): - self.extruder_rd_true = float(rd_true) - - def spring_mm(self) -> float: - return self.x_true * (self.buffer_range_mm / 2.0) - - # ---------- Simulation clock ---------- - def get_time_s(self) -> float: - """Return the current absolute simulation time (seconds, monotonic).""" - return float(self.time_s) - - def advance_time(self, dt_s: float) -> float: - """Advance the internal clock by a non-negative dt (seconds) and return new time.""" - try: - dt = max(0.0, float(dt_s)) - except Exception: - dt = 0.0 - self.time_s += dt - return self.time_s - - def reset_time(self, to: float = 0.0) -> None: - """Reset the internal clock to a non-negative value (default 0).""" - self.time_s = max(0.0, float(to)) - - # ---------- Sensor modeling ---------- - def get_switch_thresholds(self) -> Tuple[float, float]: - """ - Returns (thr_on, thr_off) in normalized units. - thr_on = magnitude to TRIGGER (go from 0 to active state) - thr_off = magnitude to UNTRIGGER (leave the active state) - """ - thr_mid = float(self.ctrl.cfg.flowguard_extreme_threshold) - thr_on = min(self._norm_clip, thr_mid * (1.0 + self.hysteresis)) - thr_off = max(0.0, min(thr_on, thr_mid * (1.0 - self.hysteresis))) - return (thr_on, thr_off) - - def _bootstrap_switch_state(self) -> None: - """Initialize last switch value from current x_meas against midpoint thresholds.""" - st = self.ctrl.cfg.sensor_type - thr = float(self.ctrl.cfg.flowguard_extreme_threshold) - if st == "D": - if self.x_meas >= thr: - self._last_switch_value = 1 - elif self.x_meas <= -thr: - self._last_switch_value = -1 - else: - self._last_switch_value = 0 - elif st == "CO": - self._last_switch_value = 1 if self.x_meas >= thr else 0 - elif st == "TO": - self._last_switch_value = -1 if self.x_meas <= -thr else 0 - else: - self._last_switch_value = 0 - - def measure(self) -> float | int: - """ - Return a sensor reading (float for 'P', int for 'D'/'CO'/'TO'), - optionally with stick–slip style lag ('chaos'). - """ - if self.chaos <= 1e-12: - self.x_meas = self.x_true - else: - # Move measured position toward true with a random "jerk". - jerk_mm_max = self.chaos * self.buffer_max_range_mm - draw_mm = random.random() * jerk_mm_max - if draw_mm >= (self.buffer_max_range_mm - 1e-12): - self.x_meas = self.x_true - else: - gap_norm = self.x_true - self.x_meas - gap_mm = abs(gap_norm) * (self.buffer_range_mm / 2.0) - if gap_mm > 1e-12: - move_mm = min(draw_mm, gap_mm) - move_norm = (2.0 / self.buffer_range_mm) * move_mm - self.x_meas += math.copysign(move_norm, gap_norm) - self.x_meas = min(max(self.x_meas, -self._norm_clip), self._norm_clip) - - if self.ctrl.cfg.sensor_type == "P": - return max(-1.0, min(1.0, self.x_meas)) - - # Switch sensors with hysteresis - thr_on, thr_off = self.get_switch_thresholds() - st = self.ctrl.cfg.sensor_type - x = self.x_meas - s_prev = self._last_switch_value - s_new = s_prev - - if st == "D": - # 3-state: -1, 0, +1 - if s_prev == 0: - if x >= +thr_on: - s_new = 1 - elif x <= -thr_on: - s_new = -1 - elif s_prev == 1: - if x <= +thr_off: - s_new = 0 - elif s_prev == -1: - if x >= -thr_off: - s_new = 0 - - elif st == "CO": - # 0 → 1 at +thr_on; 1 → 0 at +thr_off - if s_prev == 0: - if x >= +thr_on: - s_new = 1 - else: # s_prev == 1 - if x <= +thr_off: - s_new = 0 - - elif st == "TO": - # 0 → -1 at -thr_on; -1 → 0 at -thr_off - if s_prev == 0: - if x <= -thr_on: - s_new = -1 - else: # s_prev == -1 - if x >= -thr_off: - s_new = 0 - - self._last_switch_value = s_new - return s_new - -# ------------------------------- Plotting ------------------------------- - -def plot_progress( - records: List[Dict[str, Any]], - out_path: Optional[str] = None, - dt_s: Optional[float] = None, - sensor_label: Optional[str] = None, # "D", "CO", "TO" or "P" - stop_on_fg_trip: bool = False, - rd_start: Optional[float] = None, - show_rd_true: bool = True, - show_ticks: bool = False, - show_mm_axis: bool = True, - show_tick_times: bool = True, - mm_axis_mode: str = "abs", # "abs" or "signed" - summary_txt: str = "", - title_txt: str = "Filament Sync Simulation", -): - """ - Plot RD (left axis), sensor reading/UI (right axis), Bowden/Buffer spring (2nd right), and autotune-recommendation markers. - """ - if not records: - raise ValueError("No records to plot.") - - RD_COLOR = "#0000ff80" # "tab:blue" alpha=0.5 - RD_TRUE_COLOR = "#4c4c4c20" # grey 0.3 alpha=0.125 - RD_REF_COLOR = "#00800080" # green - SENSOR_COLOR = "#ff7f0e80" # "tab:orange" alpha=0.5 - SENSOR_UI_COLOR = "tab:brown" - SPRING_COLOR = "#ff000080" # red alpha=0.5 - C_EST_COLOR = "#30303080" # grey alpha=0.5 - X_EST_COLOR = "#bcbd2280" # "tab:olive" alpha=0.5 - AUTOTUNE_COLOR = "#2ca02cff" # "tab:green" alpha=1.0 - TICK_MARK_COLOR = "#40404080" # grey alpha=0.5 - HEADROOM_COLOR = "#e377c280" # "tab:pink" alpha=0.5 - LOWLIGHT_TEXT = "0.5" # grey - - # On RD axis - BOLD_LW = 3.0 - MAIN_LW = 2.0 - DEBUG_LW = 1.0 - SECONDARY_LW = 1.0 - - HEADER_ZORDER = 999 - TRIPBOX_ZORDER = 1000 - AUTOTUNE_ZORDER = 1001 - - # Prefer absolute t_s if present, else reconstruct from dt_s grid - have_ts = all(("meta" in r) and ("t_s" in r["meta"]) for r in records) - if have_ts: - t_axis = [float(r["meta"]["t_s"]) for r in records] - else: - if dt_s is None: - dt_s = records[0].get("meta", {}).get("dt_s", 1.0) - t_axis = [] - t_acc = 0.0 - for r in records: - step_dt = r.get("meta", {}).get("dt_s") - if step_dt is None: - step_dt = dt_s - t_axis.append(t_acc) - t_acc += float(step_dt) - - # Flowguard - clog = [ (str(r["output"]["flowguard"].get("trigger","")) == "clog") for r in records ] - tangle = [ (str(r["output"]["flowguard"].get("trigger","")) == "tangle") for r in records ] - first_trip_idx = next((i for i, (c, t) in enumerate(zip(clog, tangle)) if c or t), None) - last_trip_idx = next((i for i in range(len(records) - 1, -1, -1) if clog[i] or tangle[i]), None) - - # Capture trip kind/reason *before* truncation so we can show a box - trip_kind = None - trip_reason = None - trip_index = first_trip_idx if stop_on_fg_trip else last_trip_idx if last_trip_idx == len(records) - 1 else None - if trip_index is not None: - trip_kind = "CLOG" if clog[trip_index] else "TANGLE" - trip_reason = records[trip_index].get("output", {}).get("flowguard", {}).get("reason") - - # Truncate all series on limit - end_idx = (first_trip_idx + 1) if (stop_on_fg_trip and first_trip_idx is not None) else len(records) - t_axis = t_axis[:end_idx] - - # Helper for optional series - def get_optional_series(records, key, end_idx, section="output"): - vals = [r.get(section, {}).get(key) for r in records[:end_idx]] - return [] if all(v is None for v in vals) else vals - - # Core series - rd = [r["output"]["rd_current"] for r in records[:end_idx]] - rd_tuned = [r["output"].get("rd_tuned", None) for r in records[:end_idx]] - z = [r["input"]["sensor"] for r in records[:end_idx]] - z_ui = [r["output"]["sensor_ui"] for r in records[:end_idx]] - mm_deltas = [r["input"]["d_mm"] for r in records[:end_idx]] - - # Normalized headroom: -1 = full, 0 = trigger - max_r_headroom = max(r["output"]["flowguard"]["relief_headroom"] for r in records[:end_idx]) - fg_r_headroom = [-r["output"]["flowguard"]["relief_headroom"] / max_r_headroom for r in records[:end_idx]] - - # Optional debug series - x_est = get_optional_series(records, "x_est", end_idx) - c_est = get_optional_series(records, "c_est", end_idx) - rd_target = get_optional_series(records, "rd_target", end_idx) - rd_ref = get_optional_series(records, "rd_ref", end_idx) - rd_ref_smoothed = get_optional_series(records, "rd_ref_smoothed", end_idx) - rd_note = get_optional_series(records, "rd_note", end_idx) - - has_x_est = any(v is not None for v in x_est) - has_c_est = any(v is not None for v in c_est) - has_rd_target = any(v is not None for v in rd_target) - has_rd_ref = any(v is not None for v in rd_ref) - has_rd_ref_smoothed = any(v is not None for v in rd_ref_smoothed) - - # Simulator only series - rd_true = get_optional_series(records, "rd_true", end_idx, section="truth") - spring_mm = get_optional_series(records, "spring_mm", end_idx, section="truth") - - has_rd_true = any(v is not None for v in rd_true) - has_spring_mm = any(v is not None for v in spring_mm) - - # Autotune markers (where controller reported a new default RD) - autotune_recommendation = [] - for i, r in enumerate(records[:end_idx]): - auto_rd = r.get("output", {}).get("autotune", {}).get("rd") - if auto_rd is not None: - autotune_recommendation.append((i, float(auto_rd))) - - # RD update "note" markers - rd_notes = [] - for i, n in enumerate(rd_note[:end_idx]): - if rd_note[i] is not None: - rd_notes.append((i, rd[i])) - - # FlowGuard armed markers - fg_armed = [] - has_fg_armed = False - for i, r in enumerate(records[:end_idx]): - armed = r.get("output", {}).get("flowguard", {}).get("active") - if armed is False: - has_fg_armed = True - fg_armed.append((i, -1.0)) - - # Header sparators representing reset() points in controller - header_break_idxs = [ - i for i, r in enumerate(records[:end_idx]) - if r.get("meta", {}).get("header_break") is True - ] - - # Main axes setup ------------------------------------------------------------- - fig, ax_rd = plt.subplots(figsize=(12, 6), constrained_layout=True) - ax_sensor = ax_rd.twinx() - - ax_rd.set_ylabel("Rotation Distance (mm)") - ax_rd.set_xlabel("Time (s)") - ax_rd.grid(True, axis="both", alpha=0.3) - - rd0 = rd[0] if rd else 0.0 - rd_min_required = rd0 * 0.75 - rd_max_required = rd0 * 1.25 - candidates_min = [rd_min_required] - candidates_max = [rd_max_required] - if rd: - candidates_min.append(min(rd)); candidates_max.append(max(rd)) - if show_rd_true and rd_true and all(v is not None for v in rd_true): - candidates_min.append(min(rd_true)); candidates_max.append(max(rd_true)) - rd_min = min(candidates_min) - rd_max = max(candidates_max) - if rd_min == rd_max: - span = abs(rd0) * 0.25 if rd0 != 0 else 1.0 - rd_min, rd_max = rd0 - span, rd0 + span - ax_rd.set_ylim(rd_min, rd_max) - - # Sensor axes/grid - ax_sensor.set_ylabel("Sensor") - ax_sensor.set_ylim(-1.1, 1.3) - #ax_sensor.axhline(0.0, linewidth=1.0, alpha=0.4, color="0.5", zorder=0) - ax_sensor.grid(True, axis="y", alpha=0.2) - - sensor_txt = f"Type {sensor_label}" if sensor_label else "Sensor" - title_txt = f"{title_txt} — {sensor_txt}" - if trip_kind is not None: - title_txt += f" | TRIPPED at {trip_kind}" - ax_rd.set_title(title_txt, pad=18) # Extra space above the top x-axis - ax_rd.text(0.5, 1.1, summary_txt, transform=ax_rd.transAxes, ha="center", va="bottom", fontsize=6, color="0.4", wrap=True, zorder=999) - - - # Plots against RD axis (left) ------------------------------------------------ - if len(t_axis) >= 2: - # Core... - if show_ticks: - ax_rd.plot(t_axis, rd, label="rotation_distance", linestyle="-", linewidth=MAIN_LW, color=RD_COLOR, marker="o", markersize=2.5, markevery=1) - else: - ax_rd.plot(t_axis, rd, label="rotation_distance", linestyle="-", linewidth=MAIN_LW, color=RD_COLOR) - ax_rd.plot(t_axis, rd_tuned, label="rd_tuned", linestyle="-.", linewidth=DEBUG_LW, color=RD_COLOR) - - # Debug... - if has_rd_true and show_rd_true: - ax_rd.plot(t_axis, rd_true, label="rd_true (simulator)", linestyle="-", linewidth=MAIN_LW, color=RD_TRUE_COLOR) - if has_rd_ref: - ax_rd.plot(t_axis, rd_ref, label="rd_ref", linestyle="-", linewidth=DEBUG_LW, color=RD_REF_COLOR) - if has_rd_ref_smoothed: - ax_rd.plot(t_axis, rd_ref_smoothed, label="rd_ref_smoothed", linestyle="--", linewidth=DEBUG_LW, color=RD_REF_COLOR) - if has_rd_target: - ax_rd.plot(t_axis, rd_target, label="rd_target", linestyle="-.", linewidth=DEBUG_LW, color=RD_REF_COLOR) - - elif t_axis: - ax_rd.scatter(t_axis, rd, label="rotation_distance", color=RD_COLOR, zorder=3) - if show_rd_true and has_rd_true: - ax_rd.scatter(t_axis, rd_true, label="rd_true (simulator)", color=RD_TRUE_COLOR, zorder=3) - - # Autotune dots (where controller reported a new default RD) - if autotune_recommendation: - t_marks = [t_axis[i] for (i, _) in autotune_recommendation] - y_marks = [v for (_, v) in autotune_recommendation] - ax_rd.scatter(t_marks, y_marks, marker="o", s=34, color=AUTOTUNE_COLOR, edgecolors="black", linewidths=0.6, label="RD autotune", zorder=AUTOTUNE_ZORDER) - - # RD note markers - if rd_notes: - t_marks = [t_axis[i] for (i, _) in rd_notes] - y_marks = [v for (_, v) in rd_notes] - ax_rd.scatter(t_marks, y_marks, marker="x", s=15, color=RD_COLOR, label="rd_note (extreme bias)") - - # Flowguard terminator line - for i, (is_clog, is_tangle) in enumerate(zip(clog[:end_idx], tangle[:end_idx])): - if is_clog or is_tangle: - ax_rd.axvline(t_axis[i], linestyle="-.", linewidth=BOLD_LW, alpha=0.7, color="red") - - # Draw header separators - for i in header_break_idxs: - ax_rd.axvline( - t_axis[i], - linestyle="--", - linewidth=1.5, - color="0.25", - alpha=0.6, - zorder=HEADER_ZORDER, - ) - - # Plots against sensor axis (right) ------------------------------------------- - if len(t_axis) >= 2: - # Core... - ax_sensor.plot(t_axis, z, label="sensor_reading", linestyle="-", linewidth=MAIN_LW, color=SENSOR_COLOR) - ax_sensor.plot(t_axis, z_ui, label="sensor_ui", linestyle=":", linewidth=SECONDARY_LW, color=SENSOR_UI_COLOR) - - # Debug... - if has_x_est: - ax_sensor.plot(t_axis, x_est, label="x_est (debug)", linestyle="--", linewidth=DEBUG_LW, color=X_EST_COLOR) - if has_c_est: - ax_sensor.plot(t_axis, c_est, label="c_est (debug)", linestyle="--", linewidth=DEBUG_LW, color=C_EST_COLOR) - - # FlowGuard: Headroom normalized and reflected to fit on this scale - ax_sensor.plot(t_axis, fg_r_headroom, label="flowguard headroom (norm. relief)", linestyle="-.", linewidth=SECONDARY_LW, color=HEADROOM_COLOR) - - elif t_axis: - ax_sensor.scatter(t_axis, z, label="sensor_reading", color=SENSOR_COLOR, zorder=2) - ax_sensor.scatter(t_axis, z_ui, label="sensor_ui", color=SENSOR_COLOR, zorder=2) - - # FlowGuard armed markers - if has_fg_armed: - t_marks = [t_axis[i] for (i, _) in fg_armed] - y_marks = [v for (_, v) in fg_armed] - ax_sensor.scatter(t_marks, y_marks, marker="x", s=15, color=HEADROOM_COLOR, label="flowguard not active") - - - # Plots against bowden/buffer spring (far right) ------------------------------ - if has_spring_mm: - ax_spring = ax_rd.twinx() - ax_spring.spines["right"].set_position(("axes", 1.08)) - ax_spring.spines["right"].set_visible(True) - ax_spring.spines["right"].set_color(LOWLIGHT_TEXT) - ax_spring.set_frame_on(True) - ax_spring.set_facecolor("none") - - ax_spring.axhline(0.0, linewidth=1.0, alpha=0.2, color="grey") - ax_spring.set_ylabel("Simulated bowden/buffer spring (mm)", color=LOWLIGHT_TEXT, fontsize=8) - ax_spring.tick_params(axis="y", colors=LOWLIGHT_TEXT, which="both", width=1, length=4) - - y_spring = [float("nan") if v is None else float(v) for v in spring_mm] - finite_vals = [v for v in y_spring if not (math.isnan(v) or math.isinf(v))] - span = max(abs(min(finite_vals)), abs(max(finite_vals))) if finite_vals else 1.0 - lim = max(span * 1.1, 0.5) - ax_spring.set_ylim(-lim, +lim) - - if len(t_axis) >= 2: - ax_spring.plot(t_axis, y_spring, label="bowden spring (simulator)", linestyle=":", linewidth=DEBUG_LW, color=SPRING_COLOR) - elif t_axis: - ax_spring.scatter(t_axis, y_spring, label="bowden spring (simulator)", color=SPRING_COLOR, zorder=2) - - - # Top non-linear extruder mm x-axis ------------------------------------------- - if show_mm_axis and len(t_axis) >= 2 and len(mm_deltas) >= 1: - # Fixed-distance ticks mapped to main time axis - t_series = np.asarray(t_axis, dtype=float) - - mode = (mm_axis_mode or "abs").lower() - if mode == "abs": - mm_series = np.cumsum(np.abs(mm_deltas)).astype(float) - top_label = "Extruder distance (mm)" - # ensure strictly increasing for stable inverse - if np.any(np.diff(mm_series) <= 0): - mm_series = mm_series + 1e-12 * np.arange(mm_series.size) - else: - # NOTE signed displacement may be non-monotonic → inverse not unique. - # We'll still place ticks using the ABS distance for spacing, - # but show signed labels at those times. - mm_series_signed = np.cumsum(mm_deltas).astype(float) - mm_series = np.cumsum(np.abs(mm_deltas)).astype(float) # for monotonic inverse - top_label = "Extruder displacement (mm)" - if np.any(np.diff(mm_series) <= 0): - mm_series = mm_series + 1e-12 * np.arange(mm_series.size) - - # Make sure first label is exactly 0 at t0 - if mm_series.size: - mm_series[0] = 0.0 - - # Add small symmetric margin so both axes inset equally - ax_rd.margins(x=0.03) - - # Create the top axis that shares time-limits with the bottom - ax_mm = ax_rd.twiny() - ax_mm.set_xlim(ax_rd.get_xlim()) - ax_mm.spines["top"].set_color(LOWLIGHT_TEXT) - ax_mm.tick_params(axis="x", colors=LOWLIGHT_TEXT, pad=6, labelsize=6) - ax_mm.xaxis.label.set_color(LOWLIGHT_TEXT) - ax_mm.set_xlabel(top_label, fontsize=8) - ax_mm.xaxis.get_offset_text().set_size(6) - - # Fixed-distance tick placement - mm_end = float(mm_series[-1]) - - def _nice_step(x): - # 1–2–5 stepping - if x <= 0: - return 1.0 - exp = math.floor(math.log10(x)) - frac = x / (10 ** exp) - if frac <= 1.0: - nice = 1.0 - elif frac <= 2.0: - nice = 2.0 - elif frac <= 5.0: - nice = 5.0 - else: - nice = 10.0 - return nice * (10 ** exp) - - # Aim for ~10 major ticks - desired_ticks = 10 # tweak: 8, 10, 12, ... - target = max(1.0, mm_end / desired_ticks) - mm_step = _nice_step(target) - - # Build distance ticks: 0, step, 2*step, ... - m_ticks = np.arange(0.0, mm_end + 0.5 * mm_step, mm_step) - - # Optionally drop the terminal tick if it lands essentially at the end - # to avoid the last label colliding with the frame. - if len(m_ticks) >= 2 and abs(m_ticks[-1] - mm_end) < 0.25 * mm_step: - m_ticks = m_ticks[:-1] - - # Map distance ticks → time via inverse interp - t_ticks = np.interp(m_ticks, mm_series, t_series) - - # Keep ticks within the current (padded) visible time range - xmin, xmax = ax_rd.get_xlim() - keep = (t_ticks >= xmin) & (t_ticks <= xmax) - t_ticks = t_ticks[keep] - m_ticks = m_ticks[keep] - - # Labels: show signed value at those times if in 'signed' mode - if mode == "signed": - signed_vals = np.interp(t_ticks, t_series, mm_series_signed) - labels = [f"{v:.0f}" if mm_step >= 5 else f"{v:.1f}" for v in signed_vals] - else: - labels = [f"{m:.0f}" if mm_step >= 5 else f"{m:.1f}" for m in m_ticks] - - ax_mm.set_xticks(t_ticks) - ax_mm.set_xticklabels(labels) - - - # Plot times where an actual movement happened (ticks) ------------------------ - if show_tick_times: - tick_times = [t for t, d in zip(t_axis, mm_deltas)] - - # Keep them just under the top, and a bit lower if the top distance axis is shown - y_top = 0.99 - h = 0.015 # height = 1.5% of axes - y0, y1 = max(0, y_top - h), min(1, y_top) - - for x in tick_times: - ax_rd.axvline(x, ymin=y0, ymax=y1, color=TICK_MARK_COLOR, linewidth=1.0, clip_on=False, label="_nolegend_") - - - # Flowguard trip banner ------------------------------------------------------- - if trip_kind is not None: - ax_rd.text( - 0.01, 0.98, - f"{trip_kind} reason:\n{trip_reason}", - transform=ax_rd.transAxes, - va="top", ha="left", - fontsize=9, - wrap=True, - bbox=dict(boxstyle="round", facecolor="0.92", edgecolor="0.6", alpha=0.8), - zorder=TRIPBOX_ZORDER, - clip_on=False, - ) - - - # Legend box ------------------------------------------------------------------ - rd_lines, rd_labels = ax_rd.get_legend_handles_labels() - sensor_lines, sensor_labels = ax_sensor.get_legend_handles_labels() - if has_spring_mm: - spring_lines, spring_labels = ax_spring.get_legend_handles_labels() - else: - spring_lines, spring_labels = [], [] - - legend = ax_sensor.legend( - rd_lines + sensor_lines + spring_lines, - rd_labels + sensor_labels + spring_labels, - loc="lower left", - ncol=3, - fontsize=8, - framealpha=0.6, - borderpad=0.2, - labelspacing=0.25, - handlelength=2.0, - handletextpad=0.4, - columnspacing=1.2, - ) - - # Plot ------------------------------------------------------------------------ - if out_path: - plt.savefig(out_path, dpi=150) - plt.close(fig) - print(f"Saved plot to {out_path}") - else: - plt.show() - - -# ------------------------------ CLI Helpers ---------------------------- - -def _print_cli_help(): - print(""" -Commands (history enabled — use Up/Down arrows): - sim [rd] - Simulate average extruder speed for time. Uses --stride-mm chunk size per update. - inout [v_mm_s [rd]] - Simulate average extruder speed for time. Uses --stride-mm chunk size per update. - t|tick [] - Manual one update. If sensor omitted or 'auto', uses simulator sensor state. - rd - Set printer's *true* extruder rotation_distance immediately. - clog - Realistic compression-extreme test (build + stuck), stop on FlowGuard. - tangle - Realistic tension-extreme test (build + stuck), stop on FlowGuard. - clear - Reset controller (full), printer spring, and jsonl log file (tick=0). - p | plot - Save plot to sim_plot.png (always saves to file). - d | display - Display plot window (does not save). - status - Show controller/printer state - quit | q - Exit -""") - -def _summary_txt(ctrl: SyncController, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard: Optional[Dict[str, Any]], spring_mm: float): - autotune_str = "N/A" if last_autotune_rd is None else f"{last_autotune_rd:.4f}" - sensor_str = "N/A" if last_sensor is None else (f"{last_sensor:.3f}" if isinstance(last_sensor, float) else str(last_sensor)) - sensor_ui_str = "N/A" if last_sensor_ui is None else f"{last_sensor_ui:.3f}" - if isinstance(last_flowguard, dict) and last_flowguard.get('trigger'): - fg_str = f"trigger={last_flowguard.get('trigger', '')}, reason={last_flowguard.get('reason')}" - else: - fg_str = "N/A" - return f"RD={ctrl.rd_current:.4f} | Autotune={autotune_str} | sensor={sensor_str} | sensor_ui={sensor_ui_str} | Bowden/Buffer spring={spring_mm:.3f}mm | FlowGuard: {fg_str}" - -def _summary_print(ctrl: SyncController, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard: Optional[Dict[str, Any]], spring_mm: float): - summary_txt = _summary_txt(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, spring_mm) - print(f"SUMMARY: {summary_txt}") - -def _plot_from_log( - cfg: SyncControllerConfig, - logger: SimLogger, - *, - mode: str, - out_path: str = "sim_plot.png", - show_ticks: bool = False, - show_mm_axis: bool = False, - mm_axis_mode: str = "abs", - summary_txt: str = "", -): - # Drop any header rows from the in-session simulator log - raw = logger.load_all() - records = [r for r in raw if not (isinstance(r, dict) and "header" in r)] - if not records: - print("jsonl log file is empty; nothing to plot.") - return - - use_twolevel = cfg.use_twolevel_for_type_p or cfg.sensor_type in ['CO', 'TO'] - twolevel_txt = " (twoLevel)" if use_twolevel else "" - sensor_txt = f"{cfg.sensor_type}{twolevel_txt}" if cfg.sensor_type else None - try: - plot_progress( - records, - out_path=out_path if mode == "save" else None, - sensor_label=sensor_txt, - rd_start=cfg.rd_start, - show_rd_true=True, - show_ticks=show_ticks, - show_mm_axis=show_mm_axis, - mm_axis_mode=mm_axis_mode, - summary_txt=summary_txt - ) - except Exception as e: - print(f"Plotting failed: {e}") - raise e - -# -------------------------- Controller log plotting --------------------- - -def _load_log_file(path: str) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: - """ - Load a controller-generated JSON log. - Supports JSONL (one JSON object per line) or a single JSON array. - First object/element may be {"header": {...}}; subsequent entries are {"input": {...}, "output": {...}}. - Returns (header_dict_or_empty, records_list). - """ - header: Dict[str, Any] = {} - records: List[Dict[str, Any]] = [] - pending_break = False - - if not os.path.exists(path): - raise FileNotFoundError(f"No such file: {path}") - - with open(path, "r", encoding="utf-8") as f: - raw = f.read().strip() - - if not raw: - return header, records - - for line in raw.splitlines(): - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - except Exception: - continue - if isinstance(obj, dict) and "header" in obj and not header: - maybe = obj.get("header") - if isinstance(maybe, dict): - header = maybe - continue - - if isinstance(obj, dict) and "header" in obj and header: - pending_break = True - continue - - if isinstance(obj, dict) and ("input" in obj or "output" in obj): - if pending_break: - obj.setdefault("meta", {}) - obj["meta"]["header_break"] = True - pending_break = False - records.append(obj) - - return header, records - -def _plot_log_file(path: str, *, out_path: str, show_ticks: bool, show_mm_axis: bool, mm_axis_mode: str): - header, raw_records = _load_log_file(path) - if not raw_records: - print("Controller log has no data rows to plot.") - return - - # Build plot-ready records: - # - Copy each record - # - Inject per-record meta.dt_s from input.dt_s (preferred) - # - Fall back to any existing meta.dt_s if input.dt_s is absent (e.g., simulator logs) - records: List[Dict[str, Any]] = [] - for r in raw_records: - rr = dict(r) - try: - dt = float(r.get("input", {}).get("dt_s")) - except Exception: - dt = r.get("meta", {}).get("dt_s", 1.0) # defensive fallback - - rr_meta = dict(r.get("meta", {})) - rr_meta["dt_s"] = dt - rr["meta"] = rr_meta - - records.append(rr) - - # Detect whether truth is present (simulator logs carry this) - has_truth = any(isinstance(r.get("truth"), dict) for r in raw_records) - - # Pull values used only for labeling/summary if in existing log - rd_start = header.get("rd_start", None) - sensor_type = header.get("sensor_type", None) - twolevel_active = header.get("twolevel_active", None) - - # Compose a small summary from the last record - last = records[-1] - sensor_last = last.get("input", {}).get("sensor") - rd_current_last = last.get("output", {}).get("rd_current") - sensor_ui_last = last.get("output", {}).get("sensor_ui") - fg_last = last.get("output", {}).get("flowguard", {}) - # Last non-None autotune rd - autotune_last = next( - (r["output"]["autotune"]["rd"] - for r in reversed(records) - if r.get("output", {}).get("autotune", {}).get("rd") is not None), - None, - ) - spring_mm = last.get("truth", {}).get("spring_mm") - - twolevel_txt = " (twoLevel)" if twolevel_active else "" - sensor_txt = f"{sensor_type}{twolevel_txt}" if sensor_type else None - - summary_parts = [] - if rd_start is not None and rd_current_last is not None: - summary_parts.append(f"RD start={rd_start:.4f}, end={rd_current_last:.4f}") - if autotune_last is not None: - summary_parts.append(f"Autotune={autotune_last:.4f}") - if sensor_last is not None: - try: - summary_parts.append(f"sensor={float(sensor_last):.3f}") - except Exception: - summary_parts.append(f"sensor={sensor_last}") - if sensor_ui_last is not None: - summary_parts.append(f"sensor_ui={float(sensor_ui_last):.3f}") - if spring_mm is not None: - summary_parts.append(f"Bowden/Buffer spring={float(spring_mm):.3f}mm") - #if fg_last: - # summary_parts.append(f"FlowGuard: clog={fg_last.get('clog')}, tangle={fg_last.get('tangle')}, reason={fg_last.get('reason')}") - summary_txt = " | ".join(summary_parts) - - # Save… then display... - for p in [out_path, None]: - plot_progress( - records, - out_path=p, - dt_s=None, # let plot() use per-record meta.dt_s - sensor_label=sensor_txt, - rd_start=rd_start, - show_rd_true=has_truth, # show truth if present (sim logs) - show_ticks=show_ticks, - show_mm_axis=show_mm_axis, - mm_axis_mode=mm_axis_mode, - summary_txt=summary_txt, - title_txt="Debug Sync Plot" - ) - -# -------------------------- Extreme Test (realistic) ------------------- - -def _forced_extreme_test( - ctrl: SyncController, - logger: SimLogger, - printer: SimplePrinterModel, - kind: str, # "clog" or "tangle" - stride_mm: float, - dt_s_step: float, -) -> List[Dict[str, Any]]: - """ - Two-phase extreme test with a printer-side fault prelude. - Physics is driven by the model helpers so the sign convention is consistent - with the rest of the simulator. - - - Always feeds forward (+d_ext) in the ramp. - - "clog" adds *compression* per mm (positive Δx overlay). - - "tangle" adds *tension* per mm (negative Δx overlay). - - Always passes an absolute timestamp into ctrl.update(). - """ - cfg = ctrl.cfg - records: List[Dict[str, Any]] = [] - - # Feed length per tick during the test. - build_mm = max(stride_mm, cfg.buffer_range_mm / 2.0) - d_ext_build = +abs(build_mm) # forward extrude for both tests - - # Fault strength (fraction of commanded mm converted into extra relative motion) - fault_frac = 0.35 - - # Normalization and limits - K = 2.0 / cfg.buffer_range_mm - norm_limit = max(1e-9, cfg.buffer_max_range_mm / cfg.buffer_range_mm) - - # When do we consider the sensor "pegged" during the ramp? - peg_thr = max(0.9, cfg.flowguard_extreme_threshold) - peg_need = 2 # require N consecutive pegged samples - peg_count = 0 - - # Sign of the overlay term added by the fault - fault_sign = +1.0 if kind == "clog" else -1.0 - - # --------------------------- - # Phase 1: RAMP with a fault - # --------------------------- - ramp_ticks_max = 400 - for _ in range(ramp_ticks_max): - # PRE-MOVE read (for the controller's input at this boundary) - z_in = printer.measure() - - # Advance time first, then update controller with the commanded motion - rd_prev = ctrl.rd_current - sim_time_s = printer.advance_time(dt_s_step) - out = ctrl.update(sim_time_s, d_ext_build, z_in, simulation=True) - - # Physical evolution for this tick: - # 1) Base RD-mismatch physics driven by rd_prev - printer.advance_physics(rd_prev, d_ext_build) - - # 2) Fault overlay: +compression for clog, -tension for tangle - printer.x_true += K * (fault_sign * fault_frac * d_ext_build) - - # Clip to physical limits - printer.x_true = min(max(printer.x_true, -norm_limit), norm_limit) - - # Log row - rec = { - **out, - "truth": { - "rd_true": printer.extruder_rd_true, - "spring_mm": printer.spring_mm(), - "x_true": printer.x_true, - "x_meas": printer.x_meas, - }, - "meta": { - "dt_s": dt_s_step, - "t_s": sim_time_s, - "phase": "ramp", - "fault": kind, - }, - } - logger.append(rec); records.append(rec) - - # Check pegging - if cfg.sensor_type == "P": - pegged_now = (abs(printer.x_true) >= peg_thr) - elif cfg.sensor_type == "D": - m = printer.measure() - pegged_now = (m == +1 and kind == "clog") or (m == -1 and kind == "tangle") - elif cfg.sensor_type == "CO": - # CO sees compression side only; for tangle, look at the hidden side via state - pegged_now = (printer.measure() == 1) if kind == "clog" else (printer.x_true <= -peg_thr) - else: # "TO" - # TO sees tension side only; for clog, look at the hidden side via state - pegged_now = (printer.measure() == -1) if kind == "tangle" else (printer.x_true >= +peg_thr) - - peg_count = peg_count + 1 if pegged_now else 0 - if peg_count >= peg_need: - # Snap to the physical extreme so the stuck phase starts fully pegged. - printer.x_true = (+norm_limit if kind == "clog" else -norm_limit) - break - - # FlowGuard? - fg = out["output"]["flowguard"] - trip_kind = fg.get("trigger") - if trip_kind: - trip_kind = trip_kind.upper() - print(f"FlowGuard {trip_kind} detected during ramp-in") - ctrl.flowguard.reset() - return records - - # ----------------------------- - # Phase 2: STUCK (hard jam) - # ----------------------------- - stuck_ticks_max = 120 - for _ in range(stuck_ticks_max): - # Force the state to the extreme *before* sampling so measured matches stuck - printer.x_true = (+norm_limit if kind == "clog" else -norm_limit) - z_in = printer.measure() - - rd_prev = ctrl.rd_current - sim_time_s = printer.advance_time(dt_s_step) - out = ctrl.update(sim_time_s, d_ext_build, z_in, simulation=True) - - # Keep it pinned (no need to evolve physics here; the jam dominates) - printer.x_true = (+norm_limit if kind == "clog" else -norm_limit) - - rec = { - **out, - "truth": { - "rd_true": printer.extruder_rd_true, - "spring_mm": printer.spring_mm(), - "x_true": printer.x_true, - "x_meas": printer.x_meas, - }, - "meta": { - "dt_s": dt_s_step, - "t_s": sim_time_s, - "phase": "stuck", - "fault": kind, - }, - } - logger.append(rec); records.append(rec) - - fg = out["output"]["flowguard"] - trip_kind = fg.get("trigger") - if trip_kind: - trip_kind = trip_kind.upper() - print(f"FlowGuard {trip_kind} detected") - ctrl.flowguard.reset() - break - - return records - -def _make_seed_record(ctrl: SyncController, printer: SimplePrinterModel, t_s: float, sensor_val: float | int) -> Dict[str, Any]: - """Builds the very first log row in the new schema.""" - sensor_ui = float(max(-1.0, min(1.0, float(sensor_val)))) if isinstance(sensor_val, (int, float)) else 0.0 - return { - "input": { - "tick": 0, - "dt_s": 0.0, - "t_s": t_s, - "d_mm": 0.0, - "sensor": sensor_val, - }, - "output": { - "rd_target": ctrl.rd_ref, - "rd_ref": ctrl.rd_ref, - "rd_ref_smoothed": ctrl.rd_ref, - "rd_current": ctrl.rd_ref, - "rd_note": "seed", - "x_est": ctrl.state.x, - "c_est": ctrl.state.c, - "sensor_ui": sensor_ui, - "flowguard": {"trigger": "", "reason": "", "level": 0.0, "max_clog": 0.0, "max_tangle": 0.0, "active": False, "relief_headroom": -1.0}, - "autotune": {"rd": None, "note": None}, - }, - "truth": { - "rd_true": printer.extruder_rd_true, - "spring_mm": printer.spring_mm(), - "x_true": printer.x_true, - "x_meas": printer.x_meas, - }, - "meta": { - "dt_s": 0.0, - "t_s": t_s, - }, - } - -# ---------------------------------- CLI -------------------------------- - -def _run_cli(): - random.seed(time.time_ns()) - _setup_readline(history_limit=500) - - ap = argparse.ArgumentParser(description="Filament Tension Controller + Printer Simulator CLI (movement-based)") - ap.add_argument("--sensor-type", choices=["P", "D", "CO", "TO"], default="P") - ap.add_argument("--buffer-range-mm", type=float, default=8.0) - ap.add_argument("--buffer-max-range-mm", type=float, default=12.0) - ap.add_argument("--use-twolevel", dest="use_twolevel", action="store_true", help="Enable user two-level behavior for type-P sensor") - ap.set_defaults(use_twolevel=False) - ap.add_argument("--tick-dt-s", type=float, default=1.0, help="default dt used only for manual 'tick', 'clog' and 'tangle'") - ap.add_argument("--rd-start", type=float, default=20.0, help="starting extruder rotation distance") - ap.add_argument("--sensor-lag-mm", type=float, default=0.0) - ap.add_argument("--stride-mm", type=float, default=5.0, help="movement per controller update during 'sim' and extreme tests") - ap.add_argument("--initial-sensor", choices=["neutral", "random"], default="neutral", - help="Initial sensor reading used for startup/reset (default: neutral).") - ap.add_argument("--stride-only", dest="stride_only", action="store_true", help="Only update on stride boundary (suppress real-time flips)") - ap.add_argument("--chaos", type=float, default=0.0, help="Stick-slip in measured sensor: 0.0=exact (today), 2.0=max jerk.") - ap.add_argument("--sample-error", type=float, default=0, - help="Randomize each sim tick to be stride * (1 + u*sample_error) with u∈[0,1]. " - "Only increases per-tick size; the final tick catches up so total distance is exact. " - "Use 0.0 to retain the prior exact, regular tick behavior.") - ap.add_argument("--switch-hysteresis", type=float, default=0.2, - help="Hysteresis factor for switch sensors (D/CO/TO). " - "Trigger at thr*(1+factor), release at thr*(1-factor). 0 disables.") - # Artifact outputs - ap.add_argument("--log-debug", dest="log_debug", action="store_true", help="Display debug trace log entries (autotune)") - ap.set_defaults(log_debug=False) - ap.add_argument("--out", type=str, default="sim_plot.png", help="Output PNG filename for plots (default: sim_plot.png).") - ap.add_argument("--log", type=str, default="sim.jsonl", help="Simulator json log output.") - - # Controller log plotting - ap.add_argument("--plot", type=str, default=None, - help="Path to a controller-generated JSON log (JSONL or JSON array). " - "If set, the log is parsed, a plot is saved to --out and displayed,") - ap.add_argument("--show-ticks", dest="show_ticks", action="store_true", help="Show individual updates on plot") - ap.add_argument("--x-mm", choices=["off", "abs", "signed"], default="abs", - help="Top x-axis in extruder mm: 'abs' = total distance (monotonic), 'signed' = net displacement.") - args = ap.parse_args() - - # If a controller log is provided, plot it and exit (no simulator session). - if args.plot: - try: - _plot_log_file( - args.plot, - out_path=args.out, - show_ticks=bool(args.show_ticks), - show_mm_axis=(args.x_mm != "off"), - mm_axis_mode=("abs" if args.x_mm == "abs" else "signed"), - ) - except Exception as e: - print(f"Failed to plot controller log: {e}") - raise e - return - - cfg = SyncControllerConfig( - log_sync=True, # tell controller to also create log trace for debugging - buffer_range_mm=args.buffer_range_mm, - buffer_max_range_mm=args.buffer_max_range_mm, - use_twolevel_for_type_p=args.use_twolevel, - sensor_type=args.sensor_type, - rd_start=args.rd_start, - sensor_lag_mm=args.sensor_lag_mm, - ) - default_dt_s = float(args.tick_d_t if False else args.tick_dt_s) - - ctrl = SyncController(cfg) - logger = SimLogger(args.log, truncate_on_init=True) - - logger.write_header({ - "rd_start": cfg.rd_start, - "sensor_type": cfg.sensor_type, - "twolevel_active": bool(cfg.use_twolevel_for_type_p or cfg.sensor_type in ['CO', 'TO']), - "buffer_range_mm": cfg.buffer_range_mm, - "buffer_max_range_mm": cfg.buffer_max_range_mm, - "switch_hysteresis": args.switch_hysteresis, - "chaos": args.chaos, - "sample_error": args.sample_error, - }) - - printer = SimplePrinterModel( - ctrl, - extruder_rd_true=None, - initial_spring_mm=0.0, - chaos=max(0.0, min(2.0, args.chaos)), - hysteresis=max(0.0, min(0.4, args.switch_hysteresis)), - ) - - # --------- Set initial sensor state (neutral/random) BEFORE reset ---------- - if args.initial_sensor == "random": - thr = cfg.flowguard_extreme_threshold - norm_clip = max(1e-9, cfg.buffer_max_range_mm / cfg.buffer_range_mm) - - if cfg.sensor_type == "P": - x0 = random.uniform(-1.0, 1.0) - elif cfg.sensor_type == "D": - choice = random.choice([-1, 0, 1]) - if choice == 0: - x0 = random.uniform(-0.8 * thr, 0.8 * thr) - elif choice == 1: - x0 = min(norm_clip, random.uniform(thr + 0.05, thr + 0.5)) - else: - x0 = -min(norm_clip, random.uniform(thr + 0.05, thr + 0.5)) - elif cfg.sensor_type == "CO": - choice = random.choice([0, 1]) - if choice == 1: - x0 = min(norm_clip, random.uniform(thr + 0.05, thr + 0.5)) - else: - x0 = random.uniform(-min(norm_clip, thr - 0.05), thr - 0.05) - elif cfg.sensor_type == "TO": - choice = random.choice([0, -1]) - if choice == -1: - x0 = -min(norm_clip, random.uniform(thr + 0.05, thr + 0.5)) - else: - x0 = random.uniform(-thr + 0.05, min(norm_clip, thr - 0.05)) - else: - x0 = 0.0 - - printer.x_true = max(-norm_clip, min(norm_clip, x0)) - printer.x_meas = printer.x_true # start measured at modeled state - # re-bootstrap last switch value after manual override of x - printer._bootstrap_switch_state() - # --------------------------------------------------------------------- - - # Simulation clock (absolute time seconds since start) lives on the printer - printer.reset_time(0.0) - - # Take ONE reading and use it consistently for reset and the seed log row - z0 = printer.measure() - ctrl.reset(printer.get_time_s(), cfg.rd_start, z0, simulation=True) - - # Append a seed sample at t=0 so plots (and top mm axis) start at zero - seed_rec = _make_seed_record(ctrl, printer, printer.get_time_s(), z0) - logger.append(seed_rec) - - # Make the initial summary reflect the seed - last_sensor = z0 - oo = seed_rec["output"] - last_sensor_ui = oo["sensor_ui"] - last_flowguard = oo["flowguard"] - last_autotune_rd = None - - # Show whichever attribute exists on cfg - print("=== Filament Tension Controller CLI ===") - print(f" Sensor Type : {cfg.sensor_type}") - print(f" Use TwoLevel : {cfg.use_twolevel_for_type_p}") - print(f" Buffer Range (sensor) : {cfg.buffer_range_mm} mm") - print(f" Buffer Max Range : {cfg.buffer_max_range_mm} mm (physical limit)") - print(f" Autotune motion : {cfg.autotune_motion_mm} mm") - print(f" Flowguard relief : {cfg.flowguard_relief_mm} mm") - print(f" MMU Gear RD start : {cfg.rd_start} mm") - print(f" Sensor lag : {cfg.sensor_lag_mm} mm") - print(f" Simulator:") - print(f" Chaos factor : {args.chaos}") - print(f" Ext sample error : {args.sample_error}") - switch_hysteresis = args.switch_hysteresis if cfg.sensor_type != "P" else "n/a" - print(f" Switch hysteresis : {switch_hysteresis}") - print(f" Initial sensor mode : {args.initial_sensor}") - print(f" Stride per update : {args.stride_mm} mm (sim, clog & tangle)") - print(f" Default dt : {default_dt_s} s (manual 'tick' & clog/tangle test)") - print(f" JSON log : {logger.path}") - if not HAVE_READLINE: - print(" (Tip: install 'pyreadline3' on Windows to enable Up/Down history)") - _print_cli_help() - - # --------------------------------------------------------------------- - - while True: - try: - line = input("cmd> ").strip() - except (EOFError, KeyboardInterrupt): - print() - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - _plot_from_log( - cfg, logger, - mode="save", - out_path=args.out, - show_ticks=args.show_ticks, - show_mm_axis=(args.x_mm != "off"), - mm_axis_mode=("abs" if args.x_mm == "abs" else "signed"), - summary_txt=_summary_txt(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - ) # save on exit - break - - if not line: - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - _add_history(line) - low = line.lower() - - # ---------------------------------------------------------------------- - if low in ("q", "quit", "exit"): - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - break - - # ---------------------------------------------------------------------- - if low in ("h", "help"): - _print_cli_help() - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - if low in ("p", "plot"): - _plot_from_log( - cfg, logger, - mode="save", - out_path=args.out, - show_ticks=args.show_ticks, - show_mm_axis=(args.x_mm != "off"), - mm_axis_mode=("abs" if args.x_mm == "abs" else "signed"), - summary_txt=_summary_txt(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - ) - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - if low in ("d", "display"): - _plot_from_log( - cfg, logger, - mode="display", - show_ticks=args.show_ticks, - show_mm_axis=(args.x_mm != "off"), - mm_axis_mode=("abs" if args.x_mm == "abs" else "signed"), - summary_txt=_summary_txt(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - ) - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - if low == "status": - print(f" RD: {ctrl.rd_current:.4f} mm | x={ctrl.state.x:.3f} | c={ctrl.state.c:.4f} | " - f"Bowden/Buffer spring={printer.spring_mm():.3f}mm | printer_RD_true={printer.extruder_rd_true:.4f}") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - if low == "clear": - # Clear log and reconstruct printer first - logger.clear() - # Re-write header after clearing (kept consistent with startup) - logger.write_header({ - "rd_start": cfg.rd_start, - "sensor_type": cfg.sensor_type, - "twolevel_active": bool(cfg.use_twolevel_for_type_p), - "buffer_range_mm": cfg.buffer_range_mm, - "buffer_max_range_mm": cfg.buffer_max_range_mm, - "switch_hysteresis": args.switch_hysteresis, - "chaos": args.chaos, - "sample_error": args.sample_error, - }) - printer = SimplePrinterModel( - ctrl, - extruder_rd_true=printer.extruder_rd_true, - initial_spring_mm=0.0, - chaos=max(0.0, min(2.0, args.chaos)), - hysteresis=args.switch_hysteresis, - ) - - # Reset clock and controller with timestamp - printer.reset_time(0.0) - z0 = printer.measure() - ctrl.reset(printer.get_time_s(), cfg.rd_start, z0, simulation=True) - - # Seed t=0 record - seed_rec = _make_seed_record(ctrl, printer, printer.get_time_s(), z0) - logger.append(seed_rec) - - # Make the initial SUMMARY reflect the seed - last_sensor = z0 - oo = seed_rec["output"] - last_sensor_ui = oo["sensor_ui"] - last_flowguard = oo["flowguard"] - last_autotune_rd = None - - print("Controller reset, printer spring rebased, and log cleared. Tick counter reset to 0.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - if low.startswith("rd "): - parts = line.split() - if len(parts) != 2: - print("Usage: rd ") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - try: - new_rd = float(parts[1]) - except ValueError: - print("Bad numeric value.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - printer.set_extruder_rd_true(new_rd) - print(f"Printer true extruder RD set to {printer.extruder_rd_true:.4f} mm.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - if low == "clog" or low == "tangle": - kind = "clog" if low == "clog" else "tangle" - print(f"Realistic {kind} test (autotune state, stride={args.stride_mm} mm, dt={default_dt_s}s, stop on FlowGuard)...") - recs = _forced_extreme_test( - ctrl, logger, printer, - kind=kind, - stride_mm=args.stride_mm, - dt_s_step=default_dt_s, - ) - if recs: - last_sensor = recs[-1].get("input", {}).get("sensor") - oo = recs[-1].get("output", {}) - last_sensor_ui = oo.get("sensor_ui") - last_flowguard = oo.get("flowguard") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - # Distance-based simulation: "sim [rd]" - if line.startswith("sim "): - parts = line.split() - if len(parts) not in (3, 4): - print("Usage: sim []") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - try: - v = float(parts[1]); T = float(parts[2]) - rd_true = float(parts[3]) if len(parts) == 4 else None - except ValueError: - print("Bad numeric values.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - if rd_true is not None: - printer.set_extruder_rd_true(rd_true) - - total_mm = v * T - stride = abs(args.stride_mm) - max_stride = stride * (1.0 + max(0.0, args.sample_error)) - n_steps = max(1, int(round(abs(total_mm) / max(1e-9, stride)))) - per_step = math.copysign(abs(total_mm) / n_steps, total_mm) - dt_s = (abs(per_step) / abs(v)) if abs(v) > 1e-12 else default_dt_s - max_str = f"-{max_stride:.1f}mm" if args.sample_error > 0 else "" - - print( - f"Running sim: total={total_mm:.3f}mm at {v}mm/s | approx steps={n_steps} | " - f"stride≈{stride:.1f}mm{max_str} | printer_RD_true={printer.extruder_rd_true:.4f}mm | " - f"spring0={printer.spring_mm():.3f}mm ..." - ) - - # --- Helpers ------------------------------------------------------- - def _new_stride_len(rem: float) -> float: - base = max(1e-9, abs(args.stride_mm)) - if args.sample_error <= 1e-12: - return min(base, rem) - return min(base * (1.0 + random.random() * args.sample_error), rem) - - def _read_sensor(): - raw = printer.measure() - return int(raw) if cfg.sensor_type in ("CO", "TO", "D") else float(raw) - - def _next_flip_distance(sensor_type: str, z0: int, x: float, g: float, s: float): - """ - Distance (>=0) along commanded extrusion to next threshold crossing, else inf. - g = (2/BR)*(RD_true/RD_prev - 1) is Δx per +1 mm extruder (normalized). - """ - if sensor_type == "P" or abs(g) <= 1e-16: - return math.inf, None - thr_on, thr_off = printer.get_switch_thresholds() - xvel_pos = (g * s) > 0.0 - targets = [] - if sensor_type == "CO": - if z0 == 0 and xvel_pos: targets.append(+thr_on) - elif z0 == 1 and not xvel_pos: targets.append(+thr_off) - elif sensor_type == "TO": - if z0 == 0 and not xvel_pos: targets.append(-thr_on) - elif z0 == -1 and xvel_pos: targets.append(-thr_off) - elif sensor_type == "D": - if z0 == 0: - targets.append(+thr_on if xvel_pos else -thr_on) - elif z0 == 1 and not xvel_pos: - targets.append(+thr_off) - elif z0 == -1 and xvel_pos: - targets.append(-thr_off) - - lag_norm = 0.0 - if targets and printer.chaos > 1e-12: - lag_mm_max = min(cfg.buffer_max_range_mm, float(printer.chaos) * cfg.buffer_max_range_mm) - lag_norm = (2.0 / cfg.buffer_range_mm) * (random.random() * lag_mm_max) - - best_d = math.inf - best_anchor = None - norm_clip = max(1e-9, cfg.buffer_max_range_mm / cfg.buffer_range_mm) - for anchor in targets: - x_target = anchor + (lag_norm if xvel_pos else -lag_norm) - x_target = max(-norm_clip + 1e-12, min(norm_clip - 1e-12, x_target)) - d_needed = (x_target - x) / g - if d_needed * s > 1e-12: - d_abs = abs(d_needed) - if d_abs < best_d: - best_d = d_abs - best_anchor = anchor - return best_d, best_anchor - - # --- Rolling-stride event scheduler ------------------------------- - sgn = 1.0 if total_mm >= 0 else -1.0 - remaining = abs(total_mm) - stride_left = _new_stride_len(remaining) - eps = 1e-12 - is_discrete = cfg.sensor_type in ("CO", "TO", "D") - - while remaining > eps: - # PRE-MOVE read - z0 = _read_sensor() - - # RD in effect for the chunk we are about to simulate - rd_in_effect = getattr(ctrl, "rd_prev", ctrl.rd_current) - - # Normalized gain per +1 mm extruder - g = printer.gain_per_mm(rd_in_effect) - s = 1.0 if sgn > 0 else -1.0 - - # Predict next flip and stride - x_now = printer.x_true - if cfg.sensor_type == "P": - d_to_flip, _flip_anchor = math.inf, None - else: - d_to_flip, _flip_anchor = _next_flip_distance(cfg.sensor_type, int(z0), x_now, g, s) - d_to_stride = stride_left - - # Advance to earliest of (remaining, flip, stride) - chunk_abs = min(remaining, d_to_stride, d_to_flip) - d_chunk = sgn * chunk_abs - dt_chunk = (chunk_abs / abs(v)) if abs(v) > 1e-12 else default_dt_s - sim_time_s = printer.advance_time(dt_chunk) - - # Physical spring evolution using rd_in_effect - printer.advance_physics(rd_in_effect, d_chunk) - - # Measure at event boundary - z1 = _read_sensor() - - # Event classification - if is_discrete: - actually_flipped = (int(z1) != int(z0)) - else: - actually_flipped = False - predicted_flip = abs(chunk_abs - d_to_flip) <= 1e-12 - hit_stride = abs(chunk_abs - d_to_stride) <= 1e-12 - - emit = (actually_flipped and not args.stride_only) or hit_stride - if emit: - out = ctrl.update(sim_time_s, d_chunk, z1, simulation=True) - - rec = { - **out, - "truth": { - "rd_true": printer.extruder_rd_true, - "spring_mm": printer.spring_mm(), - "x_true": printer.x_true, - "x_meas": printer.x_meas, - }, - "meta": { - "dt_s": dt_chunk, - "t_s": sim_time_s, - "d_to_stride": d_to_stride, - "d_to_flip": d_to_flip, - "event": ("flip" if actually_flipped else "stride"), - "sensor_at_event": int(z1) if is_discrete else float(z1), - }, - } - logger.append(rec) - - # Session summary state - last_sensor = int(z1) if is_discrete else 0 - oo = out.get("output", {}) - last_sensor_ui = oo.get("sensor_ui", None) - last_flowguard = oo.get("flowguard", {"trigger": ""}) - - auto = oo.get("autotune", {}) - if auto.get("rd") is not None: - last_autotune_rd = auto["rd"] - print(f"AUTOTUNE: rd: {auto['rd']:.4f}, reason: {auto.get('note')}") - elif auto.get("note") and args.log_debug: - print(f"DEBUG: {auto['note']}") - - # FlowGuard trip? - fg = oo.get("flowguard", {"trigger": ""}) - trip_kind = fg.get("trigger") - if trip_kind: - trip_kind = trip_kind.upper() - print(f"FlowGuard trip; {trip_kind}. Stopping simulation.") - ctrl.flowguard.reset() # Allow continuation after trigger - break - - # Reset stride from this instant after an event - stride_left = _new_stride_len(remaining - chunk_abs) - else: - # No controller update on non-event chunk - stride_left = max(0.0, stride_left - chunk_abs) - - # Consume distance - remaining -= chunk_abs - - print(f"Simulation complete. Current RD={ctrl.rd_current:.4f}.") - print("Type 'plot' to save a plot, or 'display' to open a window.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - # Event-driven ping-pong: "inout [ []]" - if line.startswith("inout "): - parts = line.split() - if len(parts) not in (3, 4, 5): - print("Usage: inout [ []]") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - try: - move_mm = float(parts[1]) - iters = int(parts[2]) - v_mm_s = float(parts[3]) if len(parts) >= 4 else None - rd_true = float(parts[4]) if len(parts) == 5 else None - except ValueError: - print("Bad numeric values.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - if iters <= 0 or abs(move_mm) < 1e-12: - print("Nothing to do (iters<=0 or move too small).") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # Defaults - default_speed = max(1e-9, abs(args.stride_mm) / max(1e-9, default_dt_s)) - v = abs(v_mm_s) if (v_mm_s is not None and abs(v_mm_s) > 1e-12) else default_speed - if rd_true is not None: - printer.set_extruder_rd_true(rd_true) - - stride = max(1e-9, abs(args.stride_mm)) - eps = 1e-12 - is_discrete = cfg.sensor_type in ("CO", "TO", "D") - norm_clip = max(1e-9, cfg.buffer_max_range_mm / cfg.buffer_range_mm) - - def _read_sensor(): - raw = printer.measure() - return int(raw) if is_discrete else float(raw) - - def _next_flip_distance(sensor_type: str, z0: int, x: float, g: float, s: float): - if sensor_type == "P" or abs(g) <= 1e-16: - return math.inf, None - thr_on, thr_off = printer.get_switch_thresholds() - xvel_pos = (g * s) > 0.0 - targets = [] - if sensor_type == "CO": - if z0 == 0 and xvel_pos: targets.append(+thr_on) - elif z0 == 1 and not xvel_pos: targets.append(+thr_off) - elif sensor_type == "TO": - if z0 == 0 and not xvel_pos: targets.append(-thr_on) - elif z0 == -1 and xvel_pos: targets.append(-thr_off) - elif sensor_type == "D": - if z0 == 0: - targets.append(+thr_on if xvel_pos else -thr_on) - elif z0 == 1 and not xvel_pos: - targets.append(+thr_off) - elif z0 == -1 and xvel_pos: - targets.append(-thr_off) - - lag_norm = 0.0 - if targets and printer.chaos > 1e-12: - lag_mm_max = min(cfg.buffer_max_range_mm, float(printer.chaos) * cfg.buffer_max_range_mm) - lag_norm = (2.0 / cfg.buffer_range_mm) * (random.random() * lag_mm_max) - - best_d = math.inf - best_anchor = None - for anchor in targets: - x_target = anchor + (lag_norm if xvel_pos else -lag_norm) - x_target = max(-norm_clip + 1e-12, min(norm_clip - 1e-12, x_target)) - d_needed = (x_target - x) / g - if d_needed * s > 1e-12: - d_abs = abs(d_needed) - if d_abs < best_d: - best_d = d_abs - best_anchor = anchor - return best_d, best_anchor - - def _d_to_stride_net(net_since_evt: float, s: float) -> float: - """ - Distance (>=0) along current commanded direction s (+1/-1) - until |net_since_evt + s*d| >= stride. If we move opposite the - current net, abs(net) shrinks so the threshold is unreachable. - """ - a = abs(net_since_evt) - if a >= stride - 1e-12: - return 0.0 - if abs(net_since_evt) <= 1e-12: - return stride - if math.copysign(1.0, net_since_evt) == math.copysign(1.0, s): - return max(0.0, stride - a) - return math.inf - - print( - f"Running inout: move={move_mm:.3f}mm, iters={iters}, " - f"speed={v:.3f}mm/s | stride={stride:.3f}mm | " - f"printer_RD_true={printer.extruder_rd_true:.4f}mm" - ) - - emitted = 0 - net_since_event = 0.0 # signed net motion since last emitted event - - # Alternate +move, then -move, repeated - for rep in range(iters): - for phase_sign in (+1.0, -1.0): - seg_len = abs(move_mm) - sgn = math.copysign(1.0, phase_sign * (move_mm if move_mm != 0 else 1.0)) - remaining = seg_len - - while remaining > eps: - # PRE-MOVE read - z0 = _read_sensor() - - # RD in effect for this chunk - rd_in_effect = getattr(ctrl, "rd_prev", ctrl.rd_current) - - # Motion gain and direction - g = printer.gain_per_mm(rd_in_effect) - s = 1.0 if sgn > 0 else -1.0 - - # Predict next flip and next stride crossing of |net| - x_now = printer.x_true - if cfg.sensor_type == "P": - d_to_flip, _ = math.inf, None - else: - d_to_flip, _ = _next_flip_distance(cfg.sensor_type, int(z0), x_now, g, s) - d_to_stride = _d_to_stride_net(net_since_event, s) - - # Advance to the earliest of (segment end, flip, stride) - chunk_abs = min(remaining, d_to_flip, d_to_stride) - d_chunk = sgn * chunk_abs - dt_chunk = (chunk_abs / v) if v > 1e-12 else default_dt_s - sim_time_s = printer.advance_time(dt_chunk) - - # Physical spring evolution with rd_in_effect - printer.advance_physics(rd_in_effect, d_chunk) - - # Read AFTER motion - z1 = _read_sensor() - - # Event classification - if is_discrete: - actually_flipped = (int(z1) != int(z0)) - else: - actually_flipped = False - predicted_flip = abs(chunk_abs - d_to_flip) <= 1e-12 - hit_stride = (d_to_stride < math.inf) and (abs(chunk_abs - d_to_stride) <= 1e-12) - - emit = (actually_flipped and not args.stride_only) or hit_stride - if emit: - label = "flip" if actually_flipped else "stride" - out = ctrl.update(sim_time_s, d_chunk, z1, simulation=True) - - rec = { - **out, - "truth": { - "rd_true": printer.extruder_rd_true, - "spring_mm": printer.spring_mm(), - "x_true": printer.x_true, - "x_meas": printer.x_meas, - }, - "meta": { - "dt_s": dt_chunk, - "t_s": sim_time_s, - "event": label, - "phase": ("in" if sgn > 0 else "out"), - "sensor_at_event": int(z1) if is_discrete else float(z1), - }, - } - logger.append(rec) - emitted += 1 - - # Session summary state - last_sensor = int(z1) if is_discrete else float(z1) - oo = out.get("output", {}) - last_sensor_ui = oo.get("sensor_ui", None) - last_flowguard = oo.get("flowguard", {"trigger": ""}) - - auto = oo.get("autotune", {}) - if auto.get("rd") is not None: - last_autotune_rd = auto["rd"] - print(f"AUTOTUNE: rd: {auto['rd']:.4f}, reason: {auto.get('note')}") - elif auto.get("note") and args.log_debug: - print(f"DEBUG: {auto['note']}") - - # FlowGuard trip? - fg = oo.get("flowguard", {"trigger": ""}) - trip_kind = fg.get("trigger") - if trip_kind: - trip_kind = trip_kind.upper() - print(f"FlowGuard trip; {trip_kind}. Stopping inout.") - ctrl.flowguard.reset() # Allow continuation after trigger - remaining = 0.0 - rep = iters # break outer loops - break - - # Reset net tracker AFTER an emitted event - net_since_event = 0.0 - else: - # Silent advance: accumulate signed net, no controller update - net_since_event += d_chunk - - # Consume portion of the segment - remaining -= chunk_abs - # end while - # end for phase - # end for iters - - print(f"InOut complete. Emitted {emitted} event(s). Current RD={ctrl.rd_current:.4f}.") - if emitted == 0: - print("Note: No stride/flip events occurred (net never reached stride and no sensor flips).") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # ---------------------------------------------------------------------- - # Manual update: "t|tick []" - if line.startswith("t ") or line.startswith("tick "): - parts = line.split() - if parts[0] in ("t", "tick"): - parts = parts[1:] - - if len(parts) == 0: - print("Usage: t []") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - if len(parts) > 2: - print("Too many parameters. Usage: t []") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - try: - d_ext = float(parts[0]) - except ValueError: - print("Bad numeric value for d_ext_mm.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # Sensor handling (pre-move) - if len(parts) == 1 or parts[1].strip().lower() == "auto": - z = printer.measure() - else: - if cfg.sensor_type == "P": - try: - z = float(parts[1]) - except ValueError: - print("Bad sensor value. Expect float in [-1,1].") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - else: - try: - z = int(parts[1]) - except ValueError: - print("Bad sensor value. Expect integer.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - allowed = {-1, 0, 1} if cfg.sensor_type == "D" else ({0, 1} if cfg.sensor_type == "CO" else {-1, 0}) - if z not in allowed: - if cfg.sensor_type == "D": - print("Discrete sensor (D) must be -1, 0, or 1.") - elif cfg.sensor_type == "CO": - print("Compression-only sensor (CO) must be 0 or 1.") - else: - print("Tension-only sensor (TO) must be -1 or 0.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # RD that was in effect during this tick (before controller updates) - rd_in_effect = getattr(ctrl, "rd_prev", ctrl.rd_current) - - # Advance time and update controller - sim_time_s = printer.advance_time(default_dt_s) - out = ctrl.update(sim_time_s, d_ext, z, simulation=True) - - # Physical evolution using rd_in_effect for this motion - printer.advance_physics(rd_in_effect, d_ext) - - rec = { - **out, - "truth": { - "rd_true": printer.extruder_rd_true, - "spring_mm": printer.spring_mm(), - "x_true": printer.x_true, - "x_meas": printer.x_meas, - }, - "meta": { - "dt_s": default_dt_s, - "t_s": sim_time_s, - }, - } - logger.append(rec) - - last_sensor = z - oo = out["output"] - last_sensor_ui = oo["sensor_ui"] - last_flowguard = oo["flowguard"] - - print(f"RD={oo['rd_current']:.4f} | x={oo['x_est']:.3f} | c={oo['c_est']:.4f} | " - f"sensor_ui={oo['sensor_ui']:.3f} | Bowden/Buffer spring={printer.spring_mm():.3f}mm | " - f"FlowGuard: {oo['flowguard']} | Autotune: {oo['autotune']}") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - continue - - # Fallback - print("Unknown command. Type 'help' for usage.") - _summary_print(ctrl, last_autotune_rd, last_sensor, last_sensor_ui, last_flowguard, printer.spring_mm()) - -# ------------------------------- Main ---------------------------------- - -if __name__ == "__main__": - _run_cli() - diff --git a/klippy/extras/Happy-Hare/extras/mmu/__init__.py b/klippy/extras/mmu/__init__.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/__init__.py rename to klippy/extras/mmu/__init__.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu.py b/klippy/extras/mmu/mmu.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu.py rename to klippy/extras/mmu/mmu.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_calibration_manager.py b/klippy/extras/mmu/mmu_calibration_manager.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_calibration_manager.py rename to klippy/extras/mmu/mmu_calibration_manager.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_environment_manager.py b/klippy/extras/mmu/mmu_environment_manager.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_environment_manager.py rename to klippy/extras/mmu/mmu_environment_manager.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_extruder_monitor.py b/klippy/extras/mmu/mmu_extruder_monitor.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_extruder_monitor.py rename to klippy/extras/mmu/mmu_extruder_monitor.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_led_manager.py b/klippy/extras/mmu/mmu_led_manager.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_led_manager.py rename to klippy/extras/mmu/mmu_led_manager.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_logger.py b/klippy/extras/mmu/mmu_logger.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_logger.py rename to klippy/extras/mmu/mmu_logger.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_selector.py b/klippy/extras/mmu/mmu_selector.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_selector.py rename to klippy/extras/mmu/mmu_selector.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_sensor_manager.py b/klippy/extras/mmu/mmu_sensor_manager.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_sensor_manager.py rename to klippy/extras/mmu/mmu_sensor_manager.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_shared.py b/klippy/extras/mmu/mmu_shared.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_shared.py rename to klippy/extras/mmu/mmu_shared.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_sync_controller.py b/klippy/extras/mmu/mmu_sync_controller.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_sync_controller.py rename to klippy/extras/mmu/mmu_sync_controller.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_sync_controller.py3 b/klippy/extras/mmu/mmu_sync_controller.py3 similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_sync_controller.py3 rename to klippy/extras/mmu/mmu_sync_controller.py3 diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_sync_feedback_manager.py b/klippy/extras/mmu/mmu_sync_feedback_manager.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_sync_feedback_manager.py rename to klippy/extras/mmu/mmu_sync_feedback_manager.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_test.py b/klippy/extras/mmu/mmu_test.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_test.py rename to klippy/extras/mmu/mmu_test.py diff --git a/klippy/extras/Happy-Hare/extras/mmu/mmu_utils.py b/klippy/extras/mmu/mmu_utils.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu/mmu_utils.py rename to klippy/extras/mmu/mmu_utils.py diff --git a/klippy/extras/Happy-Hare/extras/mmu_encoder.py b/klippy/extras/mmu_encoder.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu_encoder.py rename to klippy/extras/mmu_encoder.py diff --git a/klippy/extras/Happy-Hare/extras/mmu_espooler.py b/klippy/extras/mmu_espooler.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu_espooler.py rename to klippy/extras/mmu_espooler.py diff --git a/klippy/extras/Happy-Hare/extras/mmu_led_effect.py b/klippy/extras/mmu_led_effect.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu_led_effect.py rename to klippy/extras/mmu_led_effect.py diff --git a/klippy/extras/Happy-Hare/extras/mmu_leds.py b/klippy/extras/mmu_leds.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu_leds.py rename to klippy/extras/mmu_leds.py diff --git a/klippy/extras/Happy-Hare/extras/mmu_machine.py b/klippy/extras/mmu_machine.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu_machine.py rename to klippy/extras/mmu_machine.py diff --git a/klippy/extras/Happy-Hare/extras/mmu_sensors.py b/klippy/extras/mmu_sensors.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu_sensors.py rename to klippy/extras/mmu_sensors.py diff --git a/klippy/extras/Happy-Hare/extras/mmu_servo.py b/klippy/extras/mmu_servo.py similarity index 100% rename from klippy/extras/Happy-Hare/extras/mmu_servo.py rename to klippy/extras/mmu_servo.py From fd9b675112b59973c89f1f700d509226aec7300b Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Thu, 26 Mar 2026 15:54:03 +0000 Subject: [PATCH 04/19] Add Happy-Hare usage notice --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index f8e8c9590c5b..877c113451e6 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,8 @@ Klipper software is Free Software. See the [license](COPYING) or read the [documentation](https://www.klipper3d.org/Overview.html). We depend on the generous support from our [sponsors](https://www.klipper3d.org/Sponsors.html). + + + +The current for of the Klipper firmware project includes [Happy-Hare](https://github.com/moggieuk/Happy-Hare). + From 0660006436c8152c5c53c6c65fdeaecd023c83bd Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Thu, 26 Mar 2026 15:54:49 +0000 Subject: [PATCH 05/19] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 877c113451e6..89c13d56e0a0 100644 --- a/README.md +++ b/README.md @@ -18,5 +18,5 @@ depend on the generous support from our -The current for of the Klipper firmware project includes [Happy-Hare](https://github.com/moggieuk/Happy-Hare). +The current fork of the Klipper firmware project includes [Happy-Hare](https://github.com/moggieuk/Happy-Hare). From 943ce46ea3950021b0238026772e106f8f49bf6c Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Thu, 26 Mar 2026 16:01:13 +0000 Subject: [PATCH 06/19] Delete unwanted files --- klippy/extras/Generated-filament_manager.cfg | 37 -- klippy/extras/Generated-filament_manager.py | 624 ------------------ .../extras/bo_idex_xy_offset_calibration.py | 379 ----------- klippy/extras/old-filament-motions.py | 184 ------ 4 files changed, 1224 deletions(-) delete mode 100644 klippy/extras/Generated-filament_manager.cfg delete mode 100644 klippy/extras/Generated-filament_manager.py delete mode 100644 klippy/extras/bo_idex_xy_offset_calibration.py delete mode 100644 klippy/extras/old-filament-motions.py diff --git a/klippy/extras/Generated-filament_manager.cfg b/klippy/extras/Generated-filament_manager.cfg deleted file mode 100644 index ad7b94f63c5a..000000000000 --- a/klippy/extras/Generated-filament_manager.cfg +++ /dev/null @@ -1,37 +0,0 @@ -# Example configuration for filament_manager -# Save this to your printer config file - -[filament_manager] -# Global settings -# Timeouts (in seconds) -default_timeout: 30 - -# Purge settings (performed after successful load) -purge_count: 3 -purge_length: 5 -purge_speed: 5 -purge_interval: 2.0 - -# Load/Unload speeds (mm/s) -load_speed: 10 -unload_speed: 10 - -# Travel speed (mm/s) -travel_speed: 100 - -# Save filament state to variables file (persists across restarts) -save_variables: True - -# Per-extruder settings (optional - will use globals if not specified) -# Uncomment and modify as needed: - -# [filament_manager extruder] -# sensors: my_switch_sensor, my_motion_sensor, cutter -# load_timeout: 20 -# unload_timeout: 15 -# load_speed: 8 -# unload_speed: 12 -# purge_count: 5 -# purge_length: 3 -# purge_speed: 3 -# save_variables: True diff --git a/klippy/extras/Generated-filament_manager.py b/klippy/extras/Generated-filament_manager.py deleted file mode 100644 index 891f4f1b1341..000000000000 --- a/klippy/extras/Generated-filament_manager.py +++ /dev/null @@ -1,624 +0,0 @@ -import enum -import logging -import typing -from functools import partial - -PLA_TEMPERATURE = 230 -PETG_TEMPERATURE = 240 -ABS_TEMPERATURE = 250 -NYLON_TEMPERATURE = 270 -DEFAULT_TEMPERATURE = 250 - -FILAMENT_TEMPERATURES = { - "PLA": PLA_TEMPERATURE, - "PETG": PETG_TEMPERATURE, - "ABS": ABS_TEMPERATURE, - "NYLON": NYLON_TEMPERATURE, -} - - -class FilamentStates(enum.Enum): - LOADING = "loading" - LOADED = "loaded" - UNLOADED = "unloaded" - UNLOADING = "unloading" - UNKNOWN = "unknown" - - -class SensorChecker: - def __init__(self, config) -> None: - self.printer = config.get_printer() - self.reactor = self.printer.get_reactor() - self.is_enabled = False - self.sensor = None - self.callback = None - self.trigger_state = False - self.current_state = False - self.check_interval = 1.5 - self.last_check_time = 0 - self.min_event_systime = self.reactor.NEVER - self.event_delay = 0.5 - self.check_timer = self.reactor.register_timer( - self.verify_sensor, self.reactor.NEVER - ) - self.printer.register_event_handler("klippy:ready", self.handle_ready) - - def handle_ready(self) -> None: - self.min_event_systime = self.reactor.monotonic() + 2.0 - - def toggle_check(self) -> None: - if not self.sensor: - return - self.is_enabled = not self.is_enabled - if self.is_enabled and self.sensor: - self.reactor.update_timer(self.check_timer, self.reactor.NOW) - return - self.reactor.update_timer(self.check_timer, self.reactor.NEVER) - - def trigger_check(self) -> None: - if self.sensor and self.callback: - self.reactor.register_callback(self.callback) - - def register_sensor(self, sensor_type: str, sensor_name: str): - self.sensor = self.printer.lookup_object(f"{sensor_type} {sensor_name}", None) - if not self.sensor: - raise self.printer.config_error( - f"Unknown Sensor {sensor_type} {sensor_name}" - ) - - def set_check_interval(self, interval: float) -> None: - self.check_interval = max(0.1, interval) - - def register_callback( - self, callback: typing.Callable[..., None], trigger: bool - ) -> None: - self.trigger_state = trigger - self.callback = callback - - def verify_sensor(self, eventtime: float) -> float: - if not self.is_enabled: - return self.reactor.NEVER - if not self.sensor: - self.printer.command_error(f"Sensor check failed") - return self.reactor.NEVER - status = self.sensor.get_status(eventtime) - filament_present = status.get("filament_detected", self.trigger_state) - if (filament_present != self.current_state) and ( - filament_present == self.trigger_state - ): - if eventtime >= self.min_event_systime: - self.current_state = filament_present - self.min_event_systime = self.reactor.NEVER - self.reactor.register_callback(self._handle_trigger) - self.last_check_time = eventtime - return eventtime + self.check_interval - - def _handle_trigger(self): - completion = self.reactor.register_callback(self.callback) - self.min_event_systime = self.reactor.monotonic() + self.event_delay - return completion.wait() - - def active(self) -> bool: - return self.is_enabled - - -class ExtruderMotions: - def __init__(self, config, extruder, name) -> None: - self.printer = config.get_printer() - self.name = name - self.reactor = self.printer.get_reactor() - self.gcode = self.printer.lookup_object("gcode") - self.pheaters = self.extruder_heater = None - self.travel_speed = config.getfloat( - "travel_speed", default=100.0, minval=50.0, maxval=500.0 - ) - self.extruder_speed = config.getfloat( - "extruder_speed", default=10.0, minval=2.0, maxval=50.0 - ) - self.printer.register_event_handler("klippy:ready", self.handle_ready) - self.printer.register_event_handler("klippy:connect", self.handle_connect) - self.extruder = extruder - self.old_extruder = None - self.toolhead = None - - def handle_connect(self) -> None: - self.toolhead = self.printer.lookup_object("toolhead") - - def handle_ready(self) -> None: - if self.toolhead: - self.old_extruder = self.toolhead.get_extruder() - - def _force_activate(self) -> None: - if not self.toolhead or not self.extruder: - return - if self.extruder != self.old_extruder: - self.old_extruder = self.toolhead.get_extruder() - self.toolhead.flush_step_generation() - last_position = self.extruder.extruder_stepper.find_past_position( - self.reactor.monotonic() - ) - self.toolhead.set_extruder(self.extruder, last_position) - self.printer.send_event("extruder:activate_extruder") - logging.info("Activated extruder %s", str(self.extruder.get_name())) - - def heat(self, temp: int, threshold: float = 0.1, wait: bool = False) -> None: - if not self.extruder: - return - eventtime = self.reactor.monotonic() - heater = self.extruder.get_heater() - if ( - (temp * (1 - threshold)) - <= heater.get_temp(eventtime)[0] - <= (temp * (1 + threshold)) - ): - heater.set_temp(temp) - while not self.printer.is_shutdown() and wait: - heater_temp, target_temp = heater.get_temp(eventtime) - if ( - (target_temp * (1 - threshold)) - <= heater_temp - <= (target_temp * (1 + threshold)) - ): - return - eventtime = self.reactor.pause(eventtime + 1.0) - - def move( - self, distance: float = 10.0, speed: float = 10.0, wait: bool = True - ) -> None: - if not self.extruder or not self.toolhead: - return - extruder_heater = self.extruder.get_heater() - if not extruder_heater.can_extrude: - raise self.printer.command_error("Extruder below minimum temperature") - self._force_activate() - eventtime = self.reactor.monotonic() - force_move = self.printer.lookup_object("force_move") - mcu = self.printer.lookup_object("mcu") - est_print_time = mcu.estimated_print_time(eventtime) - prev_position = self.extruder.find_past_position(est_print_time) - npos = prev_position + distance - force_move.manual_move( - self.extruder.extruder_stepper.stepper, - npos, - distance, - speed, - ) - if wait: - self.toolhead.wait_moves() - - -class FilamentMotions: - def __init__(self, config, name, extruder, manager): - self.config = config - self.printer = config.get_printer() - self.name = name - self.reactor = self.printer.get_reactor() - self.gcode = self.printer.lookup_object("gcode") - self.manager = manager - self.toolhead = None - self.bucket = None - self.extruder_motion = ExtruderMotions(config, extruder, name) - self.sensor_checkers: dict[str, SensorChecker] = {} - self.state: FilamentStates = FilamentStates.UNKNOWN - self.timeout = config.getint("timeout", default=30, minval=10, maxval=1000) - self.load_timeout = config.getint( - "load_timeout", default=self.timeout, minval=10, maxval=1000 - ) - self.unload_timeout = config.getint( - "unload_timeout", default=self.timeout, minval=10, maxval=1000 - ) - self.sensors = config.getlist("sensors", None) - self.load_speed = config.getfloat( - "load_speed", default=10.0, minval=1.0, maxval=50.0 - ) - self.unload_speed = config.getfloat( - "unload_speed", default=10.0, minval=1.0, maxval=50.0 - ) - self.purge_count = config.getint("purge_count", default=3, minval=0, maxval=20) - self.purge_length = config.getfloat( - "purge_length", default=5.0, minval=0.5, maxval=50.0 - ) - self.purge_speed = config.getfloat( - "purge_speed", default=5.0, minval=1.0, maxval=50.0 - ) - self.purge_interval = config.getfloat( - "purge_interval", default=2.0, minval=0.5, maxval=10.0 - ) - self.extrude_count = 0 - self.current_purge_index = 0 - self.operation_temp = DEFAULT_TEMPERATURE - self.save_variables = config.getboolean("save_variables", True) - - self.unextrude_timer = self.reactor.register_timer( - self._unextrude, self.reactor.NEVER - ) - self.extrude_timer = self.reactor.register_timer( - self._extrude, self.reactor.NEVER - ) - self.purge_timer = self.reactor.register_timer(self._purge, self.reactor.NEVER) - - self.printer.register_event_handler("klippy:ready", self.handle_ready) - self.printer.register_event_handler("klippy:connect", self.handle_connect) - - def handle_connect(self) -> None: - self.toolhead = self.printer.lookup_object("toolhead") - self.bucket = self.printer.lookup_object("bucket", None) - self._load_saved_state() - self._register_sensors() - - def handle_ready(self) -> None: - pass - - def _load_saved_state(self) -> None: - if not self.save_variables: - return - try: - save_vars = self.printer.lookup_object("save_variables") - if save_vars: - variables = save_vars.get_status(0).get("variables", {}) - saved_state = variables.get(f"filament_state_{self.name}") - if saved_state: - try: - self.state = FilamentStates(saved_state) - logging.info( - f"[FilamentManager] Loaded saved state for {self.name}: {self.state.value}" - ) - except ValueError: - logging.warning( - f"[FilamentManager] Invalid saved state for {self.name}: {saved_state}" - ) - except Exception as e: - logging.debug(f"[FilamentManager] Could not load saved state: {e}") - - def _save_state(self) -> None: - if not self.save_variables: - return - try: - self.gcode.run_script_from_command( - f'SAVE_VARIABLE VARIABLE=filament_state_{self.name} VALUE="{self.state.value}"' - ) - except Exception as e: - logging.warning(f"[FilamentManager] Could not save state: {e}") - - def _register_sensors(self) -> None: - if not self.sensors: - return - for sensor_name in self.sensors: - sensor_name = sensor_name.strip() - if not sensor_name: - continue - for sensor_type in [ - "filament_switch_sensor", - "filament_motion_sensor", - "cutter_sensor", - ]: - sensor = self.printer.lookup_object( - f"{sensor_type} {sensor_name}", None - ) - if sensor: - logging.info( - f"[FilamentManager] Registered {sensor_type} '{sensor_name}' for {self.name}" - ) - break - - def _get_temperature(self, filament_type: str) -> int: - return FILAMENT_TEMPERATURES.get(filament_type.upper(), DEFAULT_TEMPERATURE) - - def _enable_sensors(self) -> None: - for checker in self.sensor_checkers.values(): - checker.toggle_check() - - def _disable_sensors(self) -> None: - for checker in self.sensor_checkers.values(): - if checker.active(): - checker.toggle_check() - - def _unextrude(self, eventtime: float) -> float: - if self.extrude_count >= self.unload_timeout: - self._cleanup_unload(error=False) - return self.reactor.NEVER - self.extrude_count += 1 - self.extruder_motion.move(-10, self.unload_speed, wait=False) - return eventtime + float(10 / self.unload_speed) - - def _extrude(self, eventtime: float) -> float: - if self.extrude_count >= self.load_timeout: - self.reactor.update_timer(self.extrude_timer, self.reactor.NEVER) - self._start_purge() - return self.reactor.NEVER - self.extrude_count += 1 - self.extruder_motion.move(10, self.load_speed, wait=False) - return eventtime + float(10 / self.load_speed) - - def _purge(self, eventtime: float) -> float: - if self.current_purge_index >= self.purge_count: - self._cleanup_load(error=False) - return self.reactor.NEVER - self.current_purge_index += 1 - self.extruder_motion.move(self.purge_length, self.purge_speed, wait=True) - self.gcode.respond_info( - f"[FilamentManager] Purge {self.current_purge_index}/{self.purge_count}" - ) - return eventtime + self.purge_interval - - def _start_purge(self) -> None: - if self.purge_count <= 0: - self._cleanup_load(error=False) - return - if self.bucket: - self.bucket.move_to_bucket() - if self.toolhead: - self.toolhead.wait_moves() - self.current_purge_index = 0 - self.reactor.update_timer(self.purge_timer, self.reactor.NOW) - - def _cleanup_load(self, error: bool = False) -> None: - self.reactor.update_timer(self.extrude_timer, self.reactor.NEVER) - self.reactor.update_timer(self.purge_timer, self.reactor.NEVER) - self._disable_sensors() - if error: - self.state = FilamentStates.UNKNOWN - self.printer.send_event("filament_manager:error") - else: - self.state = FilamentStates.LOADED - self.printer.send_event("filament_manager:loaded") - self._save_state() - self.gcode.respond_info( - f"[FilamentManager] Load {'failed' if error else 'complete'} for {self.name}" - ) - - def _cleanup_unload(self, error: bool = False) -> None: - self.reactor.update_timer(self.unextrude_timer, self.reactor.NEVER) - self._disable_sensors() - if error: - self.state = FilamentStates.UNKNOWN - self.printer.send_event("filament_manager:error") - else: - self.state = FilamentStates.UNLOADED - self.printer.send_event("filament_manager:unloaded") - self._save_state() - self.gcode.respond_info( - f"[FilamentManager] Unload {'failed' if error else 'complete'} for {self.name}" - ) - - def _handle_load_sensor_trigger(self) -> None: - if self.state != FilamentStates.LOADING: - return - self.reactor.update_timer(self.extrude_timer, self.reactor.NEVER) - self._start_purge() - - def _handle_unload_sensor_trigger(self) -> None: - if self.state != FilamentStates.UNLOADING: - return - self.reactor.update_timer(self.unextrude_timer, self.reactor.NEVER) - self._cleanup_unload(error=False) - - def load(self, filament_type: str = "PLA") -> None: - if self.state not in [FilamentStates.UNLOADED, FilamentStates.UNKNOWN]: - raise self.printer.command_error( - f"Cannot load filament on {self.name}: current state is {self.state.value}" - ) - if not self.toolhead: - raise self.printer.command_error(f"Toolhead not available for {self.name}") - - self.state = FilamentStates.LOADING - self.printer.send_event("filament_manager:loading") - self.extrude_count = 0 - self.operation_temp = self._get_temperature(filament_type) - - self._disable_sensors() - self.sensor_checkers.clear() - - for sensor_name in self.sensors or []: - checker = SensorChecker(self.config) - try: - checker.register_sensor("filament_switch_sensor", sensor_name) - checker.set_check_interval(1.0) - checker.register_callback(self._handle_load_sensor_trigger, True) - self.sensor_checkers[f"load_{sensor_name}"] = checker - except Exception: - try: - checker.register_sensor("filament_motion_sensor", sensor_name) - checker.set_check_interval(1.0) - checker.register_callback(self._handle_load_sensor_trigger, True) - self.sensor_checkers[f"load_{sensor_name}"] = checker - except Exception: - try: - checker.register_sensor("cutter_sensor", sensor_name) - checker.set_check_interval(1.0) - checker.register_callback( - self._handle_load_sensor_trigger, True - ) - self.sensor_checkers[f"load_{sensor_name}"] = checker - except Exception as e: - logging.warning( - f"[FilamentManager] Could not register sensor {sensor_name}: {e}" - ) - - self.extruder_motion.heat(self.operation_temp, wait=True) - - self._enable_sensors() - self.reactor.update_timer(self.extrude_timer, self.reactor.NOW) - self.gcode.respond_info( - f"[FilamentManager] Loading {filament_type} at {self.operation_temp}C on {self.name}" - ) - - def unload(self) -> None: - if self.state not in [FilamentStates.LOADED, FilamentStates.UNKNOWN]: - raise self.printer.command_error( - f"Cannot unload filament on {self.name}: current state is {self.state.value}" - ) - if not self.toolhead: - raise self.printer.command_error(f"Toolhead not available for {self.name}") - - self.state = FilamentStates.UNLOADING - self.printer.send_event("filament_manager:unloading") - self.extrude_count = 0 - - self._disable_sensors() - self.sensor_checkers.clear() - - for sensor_name in self.sensors or []: - checker = SensorChecker(self.config) - try: - checker.register_sensor("filament_switch_sensor", sensor_name) - checker.set_check_interval(1.0) - checker.register_callback(self._handle_unload_sensor_trigger, False) - self.sensor_checkers[f"unload_{sensor_name}"] = checker - except Exception: - try: - checker.register_sensor("filament_motion_sensor", sensor_name) - checker.set_check_interval(1.0) - checker.register_callback(self._handle_unload_sensor_trigger, False) - self.sensor_checkers[f"unload_{sensor_name}"] = checker - except Exception: - try: - checker.register_sensor("cutter_sensor", sensor_name) - checker.set_check_interval(1.0) - checker.register_callback( - self._handle_unload_sensor_trigger, False - ) - self.sensor_checkers[f"unload_{sensor_name}"] = checker - except Exception as e: - logging.warning( - f"[FilamentManager] Could not register sensor {sensor_name}: {e}" - ) - - self.extruder_motion.heat(self.operation_temp, wait=True) - - self._enable_sensors() - self.reactor.update_timer(self.unextrude_timer, self.reactor.NEVER) - self.gcode.respond_info(f"[FilamentManager] Unloading {self.name}") - - def get_status(self, eventtime: float) -> dict: - return { - "state": self.state.value, - "extruder": self.name, - "loading": self.state == FilamentStates.LOADING, - "unloading": self.state == FilamentStates.UNLOADING, - } - - -class FilamentManager: - def __init__(self, config): - self.printer = config.get_printer() - self.reactor = self.printer.get_reactor() - self.gcode = None - self.toolhead = self.extruder_objects = self.bucket = None - self.custom_boundary = None - self.config = config - self.motions: dict[str, FilamentMotions] = {} - self.default_timeout = config.getint( - "default_timeout", default=30, minval=10, maxval=1000 - ) - self.purge_count = config.getint("purge_count", default=3, minval=0, maxval=20) - self.purge_length = config.getfloat( - "purge_length", default=5.0, minval=0.5, maxval=50.0 - ) - self.purge_speed = config.getfloat( - "purge_speed", default=5.0, minval=1.0, maxval=50.0 - ) - self.load_speed = config.getfloat( - "load_speed", default=10.0, minval=1.0, maxval=50.0 - ) - self.unload_speed = config.getfloat( - "unload_speed", default=10.0, minval=1.0, maxval=50.0 - ) - self.travel_speed = config.getfloat( - "travel_speed", default=100.0, minval=20.0, maxval=1000.0 - ) - - self.printer.register_event_handler("klippy:ready", self.handle_ready) - self.printer.register_event_handler("klippy:connect", self.handle_connect) - - def handle_connect(self) -> None: - self.toolhead = self.printer.lookup_object("toolhead") - - def handle_ready(self) -> None: - self.gcode = self.printer.lookup_object("gcode") - self.extruder_objects = self.printer.lookup_objects("extruder") - - for name, extruder in self.extruder_objects: - try: - extruder_config = self.config.getsection(f"filament_manager {name}") - except Exception: - extruder_config = self.config - self.motions[name] = FilamentMotions(extruder_config, name, extruder, self) - self.gcode.respond_info(f"[FilamentManager] Registered extruder: {name}") - - self.gcode.register_mux_command( - "LOAD_FILAMENT", - "EXTRUDER", - None, - self.cmd_LOAD_FILAMENT, - desc="Load filament into extruder", - ) - self.gcode.register_mux_command( - "UNLOAD_FILAMENT", - "EXTRUDER", - None, - self.cmd_UNLOAD_FILAMENT, - desc="Unload filament from extruder", - ) - self.gcode.register_command( - "QUERY_FILAMENT", - self.cmd_QUERY_FILAMENT, - desc="Query filament manager status", - ) - - def _get_motion(self, extruder_name: str) -> FilamentMotions: - if extruder_name not in self.motions: - raise self.printer.command_error(f"Unknown extruder: {extruder_name}") - return self.motions[extruder_name] - - def cmd_LOAD_FILAMENT(self, gcmd) -> None: - extruder_name = gcmd.get("EXTRUDER") - filament_type = gcmd.get("FILAMENT", "PLA") - - if not extruder_name and self.toolhead: - extruder_name = self.toolhead.get_extruder().get_name() - - if not extruder_name: - raise self.printer.command_error("No extruder specified") - - motion = self._get_motion(extruder_name) - - self.reactor.register_callback(partial(motion.load, filament_type)) - - def cmd_UNLOAD_FILAMENT(self, gcmd) -> None: - extruder_name = gcmd.get("EXTRUDER") - - if not extruder_name and self.toolhead: - extruder_name = self.toolhead.get_extruder().get_name() - - if not extruder_name: - raise self.printer.command_error("No extruder specified") - - motion = self._get_motion(extruder_name) - - self.reactor.register_callback(motion.unload) - - def cmd_QUERY_FILAMENT(self, gcmd) -> None: - extruder_name = gcmd.get("EXTRUDER") - - if extruder_name: - motion = self._get_motion(extruder_name) - status = motion.get_status(0) - self.gcode.respond_info(f"Filament {status['extruder']}: {status['state']}") - else: - for name, motion in self.motions.items(): - status = motion.get_status(0) - self.gcode.respond_info( - f"Filament {status['extruder']}: {status['state']}" - ) - - def get_status(self, eventtime: float) -> dict: - return { - "extruders": { - name: m.get_status(eventtime) for name, m in self.motions.items() - } - } - - -def load_config(config): - return FilamentManager(config) diff --git a/klippy/extras/bo_idex_xy_offset_calibration.py b/klippy/extras/bo_idex_xy_offset_calibration.py deleted file mode 100644 index 6cbcb0ff6133..000000000000 --- a/klippy/extras/bo_idex_xy_offset_calibration.py +++ /dev/null @@ -1,379 +0,0 @@ -from __future__ import annotations - - -class XYOffsetCalibrationTool: - """Tool that helps the calibration of the XY offsets for Idex printers.""" - - def __init__(self, config): - self.printer = config.get_printer() - self.reactor = self.printer.get_reactor() - - # self.name = config.get_name().split()[-1] - - # * Register event handlers - self.printer.register_event_handler("klippy:connect", self.handle_connect) - self.printer.register_event_handler("klippy:ready", self.handle_ready) - - self.gcode = self.printer.lookup_object("gcode") - self.enable = False - self.last_state = False - - # * Control variables - self.prime: bool = False - self.line_number: int = 0 - self.line_spacing: float = 0.0 - self.line_height: float = 0.0 - self.line_top: float = 0.0 - self.initial_x_h: float = 0.0 - self.initial_y_h: float = 0.0 - self.initial_x_h_2: float = 0.0 - self.initial_y_h_2: float = 0.0 - self.initial_x_v: float = 0.0 - self.initial_y_v: float = 0.0 - self.initial_x_v_2: float = 0.0 - self.initial_y_v_2: float = 0.0 - self.extrude_per_mm: float = 0.0 - - self.horizontal_line_spacing: float = 0.0 - self.horizontal_line_height: float = 0.0 - self.horizontal_line_top: float = 0.0 - - self.vertical_line_spacing: float = 0.0 - self.vertical_line_height: float = 0.0 - self.vertical_line_top: float = 0.0 - - self.tool_2_x_offset: float = 0.0 - self.tool_2_y_offset: float = 0.0 - - self.filament_temperature: int = 190 - # * Register gcode commands - self.gcode.register_command( - "XYCALIBRATION", - self.cmd_XYCALIBRATION, - desc=self.cmd_XYCALIBRATION_help, - ) - - # self.gcode.register_mux_command( - # "XYNOZZLEPRIME", - # "XYOffsetCalibrationTool", - # self.name, - # self.cmd_XYNOZZLEPRIME, - # self.cmd_XYNOZZLEPRIME_help, - # ) - - def handle_connect(self): - """Event handler method for when klippy connects""" - self.toolhead = self.printer.lookup_object("toolhead") - # self.configfile = self.printer.lookup_object("configfile") - # self.manual_probe = self.printer.lookup_object("manual_probe") - # self.stepper_enable = self.printer.lookup_object("stepper_enable") - # self.mcu = self.printer.lookup_object("mcu") - # printer.register_event_handler("toolhead:set_position", - # self.reset_last_position) - # printer.register_event_handler("toolhead:manual_move", - # self.reset_last_position) - # printer.register_event_handler("gcode:command_error", - # self.reset_last_position) - # printer.register_event_handler("extruder:activate_extruder", - # self._handle_activate_extruder) - # printer.register_event_handler("homing:home_rails_end", - # self._handle_home_rails_end) - # printer.register_event_handler("gcode:request_restart", self._handle_request_restart) - - # self.printer.send_event("stepper_enable:motor_off", print_time) - - # gcmd.get_int("ENABLE", 1) - - # cur_time = self.printer.get_reactor().monotonic() - # kin_status = toolhead.get_kinematics().get_status(curtime) - - # toolhead.set_position(pos, homing_axes=[2]) - # toolhead.manual_move([None, None, self.z_hop], self.z_hop_speed) - # toolhead.get_kinematics().note_z_homed() - # need_x, need_y, need_z = [gcmd.get(axis, None) is not None for axis in "XYZ"] - # g28_cmd = self.gcode.create_gcode_command("G28", "G28", new_params) - # self.prev_G28(g28_cmd) - - # toolhead.get_position() - - def handle_ready(self): - """Event handler method for when klippy is enabled""" - self.toolhead = self.printer.lookup_object("toolhead") - self.kin = self.printer.lookup_object("toolhead").get_kinematics() - - - - # self.enable = True - # self.reactor.update_timer( - # self.update_direction_timer, self.reactor.Never - # ) - # self.enable = False - - cmd_XYCALIBRATION_help = "Handles the calibration of xy axes on Idex printers." - def cmd_XYCALIBRATION(self, gcmd): - # * First home axes - self.printer.send_event( - "stepper_enable:motor_on", self.printer.get_reactor().monotonic() - ) - gcmd.respond_info("XY calibration tool for Idex printers loaded.") - gcmd.respond_info(f"{self.toolhead.get_position()}") - - gcmd.respond_info(f"{self.get_homed_axes()}") - if self.get_homed_axes() is None or self.get_homed_axes() != "xy": - gcmd.respond_info("Must home axes before running the XY offset calibration for Idex printers.") - # return # I cannot continue if there is no homing - - - - _dual_carriage_module = self.kin.dc_module - - if _dual_carriage_module is None: - gcmd.respond_info("No dual carriage config section defined. Cannot run tool") - return - - - # Set extruder 0 - _dual_carriage_module.activate_dc_mode( - index= 0, - mode= "PRIMARY" #This is the default - ) - - _cur_extruder = self.toolhead.get_extruder() - _cur_extruder_name = _cur_extruder.get_name() - - - _available_printer_objects = self.printer.objects - gcmd.respond_info(f"Available Objects : {_available_printer_objects}") - gcmd.respond_info(f"Current extruder name : {_cur_extruder_name,}") - # Check that the kin type is cartesian - - # * Maybe home axes - # _homexy = "G28" - # # * Force usage of toolhead 0 T0 - # * Get extruder names, and activate the main toolhead - # gcmd.run_script_from_command("G28") - - # # * Horizontal Print in tool 0 - _horizontal_print_tool_0 = [ - "G90", - "M83", - f"G1 X{self.initial_x_h_2} Y{self.initial_y_h - 2} E{-0.1} F{12000}", # Brim - "G1 Z0.2 E0.5 F2000", # Brim - f"G1 X{self.initial_x_h} E{(self.initial_x_h_2 - self.initial_x_h) * self.extrude_per_mm} F3000", # U turn of the brim - f"G1 Y{self.initial_y_h} E{2 * self.extrude_per_mm}", # Start of the calibration - "G91", - ] - - # # self.print_horizontal_line - # # Raise the nozze G1 Z2 E-0.1 - - # # * Vertical Print in tool 0 - # _horizontal_print_tool_0 = [ - # "G90", - # "M83 ", - # f"G1 X{self.initial_x_v - 2} Y{self.initial_y_v} E{-0.1} F{12000}", # Brim - # "G1 Z0.2 E0.5 F2000", # Brim - # f"G1 X{self.initial_y_v} E{(self.initial_y_h_2 - self.initial_x_h) * self.extrude_per_mm} F3000", # U turn of the brim - # f"G1 Y{self.initial_y_h} E{2 * self.extrude_per_mm}", # Start of the calibration - # "G91", - # ] - - # # self.print_vertical_line - - # # Park the nozzle - # # G91 - # # park_nozzle - - # # Change to tool 1 - # # prime - # # * Horizontal print in tool 1 - - # _horizontal_print_tool_1 = [ - # "G90", - # "M83", - # f"G1 X{self.initial_x_h} Y{self.initial_y_h_2 - 2 + 2} E{-0.1} F{12000}", # Brim - # "G1 Z0.2 E0.5 F2000", # Lower nozzle for brim - # f"G1 X{self.initial_x_h_2} E{(self.initial_x_h_2 - self.initial_x_h) * self.extrude_per_mm} F3000", # U turn of the brim - # f"G1 Y{self.initial_y_h_2} E{2 * self.extrude_per_mm}", # Start of the calibration - # "G91", - # ] - # # self.print_horizontal_line - - # # Raise nozzle G1 Z2 E-0.1 - - # # * Vertical Print in tool 1 - # _horizontal_print_tool_0 = [ - # "G90", - # "M83 ", - # f"G1 X{self.initial_x_v_2 + 2} Y{self.initial_y_v} E{-0.1} F{12000}", # Brim - # "G1 Z0.2 E0.5 F2000", # Brim - # f"G1 X{self.initial_y_v_2} E{(self.initial_y_h_2 - self.initial_x_h) * self.extrude_per_mm} F3000", # U turn of the brim - # f"G1 Y{self.initial_x_v_2} E{2 * self.extrude_per_mm}", # Start of the calibration - # "G91", - # ] - # # self.print_vertical_line - - # # * Park the nozzle - # # G90 - # # G1 Y150 # So the user can see the result and select the line - - # # Change to tool 0 - # # T0 - - # # Permit the user to choose the line - # pass - - # cmd_OFFSETCALIBRATION_helpe = ( - # "calls a ui to select the ranges or something i don't know" - # ) - - # def cmd_OFFSETCALIBRATION(self, gcmd): - # pass - - # cmd_XYNOZZLEPRIME_help = "Primes the nozzle that it's going to be used" - - # def cmd_XYNOZZLEPRIME(self, gcmd): - # commands = [ - # f"M109 S{self.filament_temperature}", - # "G92 E0", # Specify that Extruder is at position 0 - # "M83", # Relative coordinates - # "G1 E50 F300", # Prime the nozzle - # "G92 E0", # Specify that Extruder is at position 0 - # ] - # for command_line in commands: - # gcmd.run_script_as_command(command_line) - - def get_status(self, eventtime): - return {"last_state": self.last_state, "enabled": self.enable} - - ################################################################################ - # Helper function that are useful for performing the XY calibration - ################################################################################ - - - def print_horizontal_line(self, gcmd, callback): - gcode_lines = [ - f"G1 X{self.horizontal_line_spacing} E{ abs(self.horizontal_line_spacing * self.extrude_per_mm)}", - f"G1 Y{self.horizontal_line_height} E{abs(self.horizontal_line_height * self.extrude_per_mm)}", - f"G1 X{self.horizontal_line_top} E{ abs(self.horizontal_line_top * self.extrude_per_mm)}", - f"G1 Y{int(self.horizontal_line_height) * -1} E{abs(self.horizontal_line_height * self.extrude_per_mm)}", - ] - for line in range(self.line_number + 1): - gcmd.run_command( - next(iter(gcode_lines)) - ) # Something like this to run all the lines - - def print_vertical_lines(self, gcmd, callback): - gcode_lines = [ - f"G1 Y{self.vertical_line_spacing} E{abs(self.vertical_line_spacing * self.extrude_per_mm)}", - f"G1 X{self.vertical_line_height} E{abs(self.vertical_line_height * self.extrude_per_mm)}", - f"G1 Y{self.vertical_line_top} E{abs(self.vertical_line_top * self.extrude_per_mm)}", - f"G1 X{int(self.vertical_line_height * -1)} E{abs(self.vertical_line_height * self.extrude_per_mm)}", - ] - for line in range(self.line_number + 1): - gcmd.run_command( - next(iter(gcode_lines)) - ) # Something like this to run all the lines - - def update_offsets(self): - pass - - def get_homed_axes(self) -> str | None: - cur_time = self.reactor.monotonic() - _kin_status = self.kin.get_status(cur_time) - - if not isinstance(_kin_status, dict): - return None - - if "homed_axes" in _kin_status.keys(): - return _kin_status["homed_axes"] - - return None - - # gcmd respond_info #Displays stuff on the console - # gcode run_script #Don't know what this one does Same as run_script_from_command but with mutex - # gcode run_script_from_command #Runs a command from a string - # toolhead move #Moves the toolhead - # toohead wait_moves() #Waits for the toolhead to finish its movements - - # When i have the tool head i can use the move command move(newpos, speed) - # There is also on toolhead a method called check_busy - # also a get_kinematics - # - - -def load_config(config): - return XYOffsetCalibrationTool(config) - - -# def load_config_prefix(config): -# return XYOffsetCalibrationTool(config) - - - -""" -From the kinmatics i can run - -get_steppers() -calc_position(self, stepper_positions) -set_position(self, newpos, homign_axes) -note_z_not_homed(self) -home(homign_state) -_motor_off(slef, pritne_time) -_check_positions(slef, move) -check_move(self, move) -get_status - - -variables: -self.rails -max_velocity, max_accel -max_z_velocity -max_z_accel -limits -ranges -axes_min -axes_max - - -From the main mcu class - - -canbus_uuid -setup_pin -create_oid(self) -get_printer -get_name -register_response(self, cb, msg, oid=None) -get_query_slot(self, oid) -seconds_to_clock(self, time) -get_max_stepper_error(self) -alloc_command_queue(self) - -lookup_command(self, msgformat, cp=None) -lookup_query_command(sef, msgformat, respformat, oid=None, cp=None, is_async=None) -estimate_print_time -get_contatns() -get_enumerations() -get_constants_float() -register_stepqueue(self, stepqueue) -request_move_queue_slot(self) -register_flush_callback(self, callback) -flush_move(self, printe_time, clear_history_time) -check_active() -is_fileoutput(self) -is_shutdown() -stats() - - -can also go get the extruder object so i can run some specific methods - - - - - - - -No final quando tiver-mos os valores, colocamos um gcode offset na segunda cabeça com o valor obtido -cada vez que cada cabeca e activada colocamos um offse, na cabela um e 0,0 e a segunda cabeca e o valor obtido -""" \ No newline at end of file diff --git a/klippy/extras/old-filament-motions.py b/klippy/extras/old-filament-motions.py deleted file mode 100644 index b63dfa2c5e0b..000000000000 --- a/klippy/extras/old-filament-motions.py +++ /dev/null @@ -1,184 +0,0 @@ -class FilamentMotions: - def __init__(self, config, name, extruder): - self.printer = config.get_printer() - self.reactor = self.printer.get_reactor() - self.name = config.get_name().split()[-1] - - self.identifier: str = f"FM-{name}" - - self.extrude_count: int = 0 - - self.emotion: ExtruderMotion = ExtruderMotion(config, extruder, name) - self.sensor_notes: dict[str, SensorChecker] = {} - # self.unextrude_timer = self.reactor.register_timer( - # self._unextrude, self.reactor.NEVER - # ) - self.printer.register_event_handler("klippy:ready", self.handle_ready) - self.printer.register_event_handler("klippy:connect", self.handle_connect) - - # Register motions sensors - # sensor_check = SensorChecker(printer) - # sensor_check.register_sensor(sensor_type, name) - # sensor_check.set_check_interval(interval) - # sensor_check.register_callback(callback, trigger) - # self.sensor_notes.update({name: sensor_check}) - - def handle_connect(self) -> None: - # TODO : fetch here the last filament state saved on variables file - save_vars = self.printer.lookup_object("save_variables", None) - if not save_vars: - self.state = FilamentStates.UNKNOWN - # Normal toolhead filament states will be FT == FT0, FT1, ..., FTX - sv_dict = save_vars.get_status(self.reactor.monotonic()) - variables: dict[str, typing.Any] = sv_dict.get("variables", None) - if not variables: - self.state = FilamentStates.UNKNOWN - - # TODO : Make this fetch last staste logic here - # variables.get() - # index_c = 0 - # dict.get() - # - # for key, item in variables.items(): - # if key == - # - gcode = self.printer.lookup_object("gcode") - self.bucket = self.printer.lookup_object("bucket", None) - if self.cutter_name: - if self.printer.lookup_object(f"cutter_sensor {self.cutter_name}", None): - self.cutter = self.printer.lookup_object( - f"cutter_sensor {self.cutter_name}" - ) - else: - raise self.printer.config_error( - f"{self.cutter_name} is undefined, expected cutter_sensor object" - ) - elif self.debug > 0: - gcode.respond_info( - f"Not using cutter_sensor for filament manager on extruder: {self.emotion.name}" - ) - logging.info( - f"Not using cutter_sensor for filament manager on extruder: {self.emotion.name}" - ) - - if self.aux_extruder_name: - if self.printer.lookup_object( - f"extruder_stepper {self.aux_extruder_name}", None - ): - self.aux_extruder = self.printer.lookup_object( - f"extruder_stepper {self.aux_extruder_name}" - ) - else: - raise self.printer.config_error( - f"{self.aux_extruder_name} is undefined, expected extruder_stepper object" - ) - elif self.debug > 0: - gcode.respond_info(f"Not using auxiliar extruder on filament manager") - logging.info(f"Not using auxiliar extruder on filament manager") - - def handle_ready(self) -> None: - """Handle `klippy:ready` event""" - # XXX: Klipper recomends not raising errors here - pass - - def _initialize_motion( - self, - eventtime: float | None, - motion_type: MotionType, - clean: bool = True, - ) -> None: - """Initialize filament motion motion""" - # if motion_type.lower() not in Motion: - # raise self.printer.command_error("Motion type expected either load or unload") - # - gcode = self.printer.lookup_object("gcode") - gcode.respond_info(f"initializing filament motion: {motion_type}") - gcode_macro = self.printer.lookup_object("gcode_macro") - toolhead = self.printer.lookup_object("toolhead") - if clean: - gcode.run_script_from_command("CLEAN_NOZZLE") - - if self.bucket: - gcode.respond_info(f"Moving toolhead FT{self.identifier}") - self.bucket.move_to_bucket(split=False) - - def _deregister_sensor(self, name) -> None: - """Deletes a sensor checker object by name""" - sensor_checker: SensorChecker = self.sensor_notes.pop(name) - del sensor_checker - - # def _unextrude(self, eventtime) -> float: - # if self.timeout: - # if self.extrude_count >= self.timeout: - # self.sensor_notes.get( - # self. - # ).toggle_check() # TEST: Stop the sensor checking - # completion = self.reactor.register_callback( - # self._handle_unload_finish() - # ) - # _ = completion.wait() - # return self.reactor.NEVER - # self.extrude_count += 1 - # self.emotion.move(distance=-1.0, speed=self.speed) - # return float(eventtime + float(1.0 / self.speed)) - # - # def _handle_unload_sensor_trigger(self, eventtime) -> None: - # if not self.sensors_check.get("unload-helper").active(): - # return - # if self.state == FilamentStates.UNLOADING: - # self.sensors_check.get("unload-helper").trigger_check() - - def unload(self) -> None: - gcode = self.printer.lookup_object("gcode") - if self.state != FilamentStates.LOADED: - raise gcode.error( - f"Cannot unload \n Extruder {self.name} is currently loading or unloaded." - ) - self.state = FilamentStates.UNLOADING - self.extrude_count = 0 - if self.cutter: - completion = self.reactor.register_callback(self.cutter.cut()) - completion.wait() - # self.unextrude_timer.update(self.reactor.NOW) - # self.sensors_check.get("unload-helper").trigger_check() - # - - def _handle_unload_finish(self, eventtime=None) -> None: - gcode = self.printer.lookup_object("gcode") - gcode.respond_info("Unload end method") - - # def _extrude(self, eventtime) -> None: - # if self.timeout: - # if self.extrude_count >= self.timeout: - # self.sensors_check.get(self.extrude_control_sensor_name).trigger_check() - # completion = self.reactor.register_callback(self.extrude_end) - # return completion.wait() - # self.extrude_count += 1 - # self.emotion.move(1, self.speed) - # return float(eventtime + float(1 / self.speed)) - # - # def _handle_load_sensor_trigger(self, eventtime) -> None: - # if not self.sensors_check.get("load-helper").active(): - # return - # if self.state == typing.Literal["loading"]: - # self.sensors_check.get("load-helper").trigger_check() - # - # cut - # register unextrude start when cut signals that is actually cut - # start helper sensor verification - # stop unextrude on timeout or when sensor is triggered - # clean or end with error - - # def load(self) -> None: - # gcode = self.printer.lookup_object("gcode") - # if self.state != FilamentStates.UNLOADED: - # raise gcode.error( - # f"Cannot load \n Extruder {self.name} is currently loaded or unloading" - # ) - # self.state = FilamentStates.LOADING - # self.extrude_count = 0 - # # start sensor verification - # # start extrude - # # stop extrude on cutter sensor or when timeout is reached - # # purge or end with error - # From f764efd265ac78f4706c8c82ff318b0727ac32a0 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Wed, 1 Apr 2026 11:36:55 +0100 Subject: [PATCH 07/19] Refactor --- klippy/extras/bed_custom_bound.py | 37 +++++-------------------------- 1 file changed, 5 insertions(+), 32 deletions(-) diff --git a/klippy/extras/bed_custom_bound.py b/klippy/extras/bed_custom_bound.py index d239a9a78728..fc28b75165f8 100644 --- a/klippy/extras/bed_custom_bound.py +++ b/klippy/extras/bed_custom_bound.py @@ -1,6 +1,4 @@ -import logging import typing -from collections import OrderedDict class BedCustomBound: @@ -8,21 +6,13 @@ def __init__(self, config): self.printer = config.get_printer() self.reactor = self.printer.get_reactor() self.gcode = self.printer.lookup_object("gcode") - - # * Register event handlers self.printer.register_event_handler("klippy:ready", self.handle_ready) - - # * Get module configs self.custom_boundary_x = None if config.getfloatlist("custom_boundary_x", None, count=2) is not None: - self.custom_boundary_x = config.getfloatlist( - "custom_boundary_x", count=2 - ) + self.custom_boundary_x = config.getfloatlist("custom_boundary_x", count=2) self.custom_boundary_y = None if config.getfloatlist("custom_boundary_y", None, count=2) is not None: - self.custom_boundary_y = config.getfloatlist( - "custom_boundary_y", count=2 - ) + self.custom_boundary_y = config.getfloatlist("custom_boundary_y", count=2) self.park = None if config.getfloatlist("park_xy", None, count=2) is not None: self.park = config.getfloatlist("park_xy", count=2) @@ -30,11 +20,7 @@ def __init__(self, config): self.travel_speed = config.getfloat( "travel_speed", 100.0, above=1.0, minval=1.0, maxval=300.0 ) - - # * Variables - self.min_event_systime = self.reactor.NEVER self.default_limits_x = self.default_limits_y = None - # * Register new gcode commands self.gcode.register_command( "SET_CUSTOM_BOUNDARY", self.cmd_SET_CUSTOM_BOUNDARY, @@ -56,9 +42,7 @@ def cmd_SET_CUSTOM_BOUNDARY(self, gcmd): return if not self.custom_boundary_x or not self.custom_boundary_y: return - move_to_custom_pos = gcmd.get("MOVE_TO_PARK", False, parser=bool) - self.set_custom_boundary() if move_to_custom_pos and self.park: self.toolhead.manual_move( @@ -112,13 +96,10 @@ def set_custom_boundary(self, eventtime=None): self.custom_boundary_y[1], ) # Y min , Y max self.current_boundary = "custom" - return def move_to_park(self): if self.park: - self.toolhead.manual_move( - [self.park[0], self.park[1]], self.travel_speed - ) + self.toolhead.manual_move([self.park[0], self.park[1]], self.travel_speed) def check_boundary_limits( self, position: typing.Tuple[float, float], bound_type: str = "default" @@ -131,11 +112,7 @@ def check_boundary_limits( "y": True, } - if ( - bound_type == "default" - and self.default_limits_x - and self.default_limits_y - ): + if bound_type == "default" and self.default_limits_x and self.default_limits_y: min_limit_x, max_limit_x = ( self.default_limits_x[0], self.default_limits_x[1], @@ -149,11 +126,7 @@ def check_boundary_limits( kin = self.toolhead.get_kinematics() min_limit_x, max_limit_x = kin.limits[0][0], kin.limits[0][1] min_limit_y, max_limit_y = kin.limits[1][0], kin.limits[1][1] - if ( - bound_type == "custom" - and self.custom_boundary_x - and self.custom_boundary_y - ): + if bound_type == "custom" and self.custom_boundary_x and self.custom_boundary_y: min_limit_x, max_limit_x = ( self.custom_boundary_x[0], self.custom_boundary_x[1], From 7a08a9ad93d56dcf94de5e05bb84e6f44fcd47f1 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Mon, 11 May 2026 16:14:11 +0100 Subject: [PATCH 08/19] Refactor custom bed boudary module --- klippy/extras/bed_custom_bound.py | 121 +++++++++++++++++++----------- 1 file changed, 76 insertions(+), 45 deletions(-) diff --git a/klippy/extras/bed_custom_bound.py b/klippy/extras/bed_custom_bound.py index fc28b75165f8..35be74a96ff6 100644 --- a/klippy/extras/bed_custom_bound.py +++ b/klippy/extras/bed_custom_bound.py @@ -1,6 +1,16 @@ import typing +############################### +# Example configuration +# +# [bed_custom_bound] +# custom_boundary_x: 0.0, 500.0 +# custom_boundary_y: 0.0, 500.0 +# travel_speed: 50.0 +# park_xy: 0., 500 +############################### + class BedCustomBound: def __init__(self, config): self.printer = config.get_printer() @@ -9,10 +19,14 @@ def __init__(self, config): self.printer.register_event_handler("klippy:ready", self.handle_ready) self.custom_boundary_x = None if config.getfloatlist("custom_boundary_x", None, count=2) is not None: - self.custom_boundary_x = config.getfloatlist("custom_boundary_x", count=2) + self.custom_boundary_x = config.getfloatlist( + "custom_boundary_x", count=2 + ) self.custom_boundary_y = None if config.getfloatlist("custom_boundary_y", None, count=2) is not None: - self.custom_boundary_y = config.getfloatlist("custom_boundary_y", count=2) + self.custom_boundary_y = config.getfloatlist( + "custom_boundary_y", count=2 + ) self.park = None if config.getfloatlist("park_xy", None, count=2) is not None: self.park = config.getfloatlist("park_xy", count=2) @@ -44,9 +58,14 @@ def cmd_SET_CUSTOM_BOUNDARY(self, gcmd): return move_to_custom_pos = gcmd.get("MOVE_TO_PARK", False, parser=bool) self.set_custom_boundary() + if move_to_custom_pos and self.park: + self.gcode.respond_info( + f" PARK POSITION FLOAT CONVERTED {float(self.park[0])} {float(self.park[1])} " + ) + self.toolhead.manual_move( - [self.park[0], self.park[1]], + [float(self.park[0]), float(self.park[1])], self.travel_speed, ) @@ -58,17 +77,20 @@ def restore_default_boundary(self, eventtime=None): return if not self.default_limits_x or not self.default_limits_y: return + self.gcode.respond_info( - "[CUSTOM BED BOUNDARY] Restoring printer boundary limits." + f"[CUSTOM BED BOUNDARY] Restoring printer boundary limits. {self.default_limits_x} {self.default_limits_y}" ) + kin = self.toolhead.get_kinematics() + self.gcode.respond_info(f"kinematics limits {kin.limits}") kin.limits[0] = ( - self.default_limits_x[0], - self.default_limits_y[1], + float(self.default_limits_x[0]), + float(self.default_limits_x[1]), ) # X min, X max kin.limits[1] = ( - self.default_limits_y[0], - self.default_limits_y[1], + float(self.default_limits_y[0]), + float(self.default_limits_y[1]), ) # Y min , Y max self.current_boundary = "default" return @@ -82,24 +104,33 @@ def set_custom_boundary(self, eventtime=None): "[CUSTOM BED BOUNDARY] Setting specified custom boundary" ) kin = self.toolhead.get_kinematics() + + self.gcode.respond_info(f"kinematics limits {kin.limits}") + self.default_limits_x, self.default_limits_y = ( kin.limits[0], kin.limits[1], ) kin.limits[0] = ( - self.custom_boundary_x[0], - self.custom_boundary_x[1], + float(self.custom_boundary_x[0]), + float(self.custom_boundary_x[1]), ) # X min, X max kin.limits[1] = ( - self.custom_boundary_y[0], - self.custom_boundary_y[1], + float(self.custom_boundary_y[0]), + float(self.custom_boundary_y[1]), ) # Y min , Y max + + self.gcode.respond_info( + f"Custom boundaries set to X=[{self.custom_boundary_x}] Y=[{self.custom_boundary_y}]" + ) self.current_boundary = "custom" def move_to_park(self): if self.park: - self.toolhead.manual_move([self.park[0], self.park[1]], self.travel_speed) + self.toolhead.manual_move( + [self.park[0], self.park[1]], self.travel_speed + ) def check_boundary_limits( self, position: typing.Tuple[float, float], bound_type: str = "default" @@ -107,40 +138,40 @@ def check_boundary_limits( if not self.toolhead or not position: return - _limits = { - "x": True, - "y": True, - } - - if bound_type == "default" and self.default_limits_x and self.default_limits_y: - min_limit_x, max_limit_x = ( - self.default_limits_x[0], - self.default_limits_x[1], - ) - min_limit_y, max_limit_y = ( - self.default_limits_y[0], - self.default_limits_y[1], - ) - - if bound_type == "current": + min_limit_x = max_limit_x = min_limit_y = max_limit_y = None + + if ( + bound_type == "default" + and self.default_limits_x + and self.default_limits_y + ): + min_limit_x = float(self.default_limits_x[0]) + max_limit_x = float(self.default_limits_x[1]) + min_limit_y = float(self.default_limits_y[0]) + max_limit_y = float(self.default_limits_y[1]) + elif bound_type == "current": kin = self.toolhead.get_kinematics() - min_limit_x, max_limit_x = kin.limits[0][0], kin.limits[0][1] - min_limit_y, max_limit_y = kin.limits[1][0], kin.limits[1][1] - if bound_type == "custom" and self.custom_boundary_x and self.custom_boundary_y: - min_limit_x, max_limit_x = ( - self.custom_boundary_x[0], - self.custom_boundary_x[1], - ) - min_limit_y, max_limit_y = ( - self.custom_boundary_y[0], - self.custom_boundary_y[1], - ) + min_limit_x = float(kin.limits[0][0]) + max_limit_x = float(kin.limits[0][1]) + min_limit_y = float(kin.limits[1][0]) + max_limit_y = float(kin.limits[1][1]) + elif ( + bound_type == "custom" + and self.custom_boundary_x + and self.custom_boundary_y + ): + min_limit_x = float(self.custom_boundary_x[0]) + max_limit_x = float(self.custom_boundary_x[1]) + min_limit_y = float(self.custom_boundary_y[0]) + max_limit_y = float(self.custom_boundary_y[1]) + + if None in (min_limit_x, max_limit_x, min_limit_y, max_limit_y): + return None - if min_limit_x < position[0] or max_limit_x < position[0]: - _limits.update({"x": False}) - - if min_limit_y < position[1] or max_limit_y < position[1]: - _limits.update({"y": False}) + _limits = { + "x": min_limit_x <= position[0] and position[0] <= max_limit_x, + "y": min_limit_y <= position[1] and position[1] <= max_limit_y, + } return _limits From b88f0af73d7a6060e47ddcbcd0c8466a7066d51a Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Fri, 15 May 2026 13:16:30 +0100 Subject: [PATCH 09/19] Ref return logic for checking bound limits --- klippy/extras/bed_custom_bound.py | 33 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/klippy/extras/bed_custom_bound.py b/klippy/extras/bed_custom_bound.py index 35be74a96ff6..03461a86dbda 100644 --- a/klippy/extras/bed_custom_bound.py +++ b/klippy/extras/bed_custom_bound.py @@ -11,6 +11,7 @@ # park_xy: 0., 500 ############################### + class BedCustomBound: def __init__(self, config): self.printer = config.get_printer() @@ -60,10 +61,6 @@ def cmd_SET_CUSTOM_BOUNDARY(self, gcmd): self.set_custom_boundary() if move_to_custom_pos and self.park: - self.gcode.respond_info( - f" PARK POSITION FLOAT CONVERTED {float(self.park[0])} {float(self.park[1])} " - ) - self.toolhead.manual_move( [float(self.park[0]), float(self.park[1])], self.travel_speed, @@ -79,11 +76,10 @@ def restore_default_boundary(self, eventtime=None): return self.gcode.respond_info( - f"[CUSTOM BED BOUNDARY] Restoring printer boundary limits. {self.default_limits_x} {self.default_limits_y}" + f"[CUSTOM BED BOUNDARY] Restoring printer boundary limits" ) kin = self.toolhead.get_kinematics() - self.gcode.respond_info(f"kinematics limits {kin.limits}") kin.limits[0] = ( float(self.default_limits_x[0]), float(self.default_limits_x[1]), @@ -105,8 +101,6 @@ def set_custom_boundary(self, eventtime=None): ) kin = self.toolhead.get_kinematics() - self.gcode.respond_info(f"kinematics limits {kin.limits}") - self.default_limits_x, self.default_limits_y = ( kin.limits[0], kin.limits[1], @@ -121,9 +115,6 @@ def set_custom_boundary(self, eventtime=None): float(self.custom_boundary_y[1]), ) # Y min , Y max - self.gcode.respond_info( - f"Custom boundaries set to X=[{self.custom_boundary_x}] Y=[{self.custom_boundary_y}]" - ) self.current_boundary = "custom" def move_to_park(self): @@ -168,12 +159,22 @@ def check_boundary_limits( if None in (min_limit_x, max_limit_x, min_limit_y, max_limit_y): return None - _limits = { - "x": min_limit_x <= position[0] and position[0] <= max_limit_x, - "y": min_limit_y <= position[1] and position[1] <= max_limit_y, - } + va = all([min_limit_x, max_limit_x, min_limit_y, max_limit_y]) + if va : + _limits = { + "x": bool(min_limit_x < position[0] < max_limit_x), + "y": bool(min_limit_y < position[1] < max_limit_y), + } + return _limits + return None + + # _limits = {} + # if min_limit_x < position[0] or max_limit_x < position[0]: + # _limits.update({"x": False}) + + # if min_limit_y < position[1] or max_limit_y < position[1]: + # _limits.update({"y": False}) - return _limits def get_status(self, eventtime=None): """Get the status of the current boundary""" From 5b33e38ee9148e63bc437020c71f40217bc8a96f Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Wed, 20 May 2026 16:52:24 +0100 Subject: [PATCH 10/19] Add beacon module --- klippy/extras/beacon.py | 3944 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 3944 insertions(+) create mode 100644 klippy/extras/beacon.py diff --git a/klippy/extras/beacon.py b/klippy/extras/beacon.py new file mode 100644 index 000000000000..6e9912cf3124 --- /dev/null +++ b/klippy/extras/beacon.py @@ -0,0 +1,3944 @@ +# Beacon eddy current scanner support +# +# Copyright (C) 2020-2023 Matt Baker +# Copyright (C) 2020-2023 Lasse Dalegaard +# Copyright (C) 2023 Beacon +# +# This file may be distributed under the terms of the GNU GPLv3 license. +import threading +import multiprocessing +import subprocess +import os +import importlib +import traceback +import logging +import chelper +import pins +import math +import time +import queue +import struct +import numpy as np +import copy +import collections +import itertools +from numpy.polynomial import Polynomial +from . import manual_probe +from . import probe +from . import bed_mesh +from . import thermistor +from . import adxl345 +from .homing import HomingMove +from mcu import MCU, MCU_trsync +from clocksync import SecondarySync +import configfile +import msgproto + +STREAM_BUFFER_LIMIT_DEFAULT = 100 +STREAM_TIMEOUT = 1.0 +API_DUMP_FIELDS = ["dist", "temp", "pos", "freq", "time"] + +TRSYNC_TIMEOUT_DEFAULT = 0.025 + + +class BeaconProbe: + def __init__(self, config, sensor_id): + self.id = sensor_id + self.printer = printer = config.get_printer() + self.reactor = printer.get_reactor() + self.name = config.get_name() + self.gcode = printer.lookup_object("gcode") + + self.speed = config.getfloat("speed", 5.0, above=0.0) + self.lift_speed = config.getfloat("lift_speed", self.speed, above=0.0) + self.backlash_comp = config.getfloat("backlash_comp", 0.5) + + self.x_offset = config.getfloat("x_offset", 0.0) + self.y_offset = config.getfloat("y_offset", 0.0) + + self.trigger_distance = config.getfloat("trigger_distance", 2.0) + self.trigger_dive_threshold = config.getfloat("trigger_dive_threshold", 1.0) + self.trigger_hysteresis = config.getfloat("trigger_hysteresis", 0.006) + self.z_settling_time = config.getint("z_settling_time", 5, minval=0) + self.default_probe_method = config.getchoice( + "default_probe_method", + {"contact": "contact", "proximity": "proximity"}, + "proximity", + ) + + # If using paper for calibration, this would be .1mm + self.cal_nozzle_z = config.getfloat("cal_nozzle_z", 0.1) + self.cal_floor = config.getfloat("cal_floor", 0.2) + self.cal_ceil = config.getfloat("cal_ceil", 5.0) + self.cal_speed = config.getfloat("cal_speed", 1.0) + self.cal_move_speed = config.getfloat("cal_move_speed", 10.0) + + self.autocal_max_speed = config.getfloat("autocal_max_speed", 10) + self.autocal_speed = config.getfloat("autocal_speed", 3) + self.autocal_accel = config.getfloat("autocal_accel", 100) + self.autocal_retract_dist = config.getfloat("autocal_retract_dist", 2) + self.autocal_retract_speed = config.getfloat("autocal_retract_speed", 10) + self.autocal_sample_count = config.getfloat("autocal_sample_count", 3) + self.autocal_tolerance = config.getfloat("autocal_tolerance", 0.008) + self.autocal_max_retries = config.getfloat("autocal_max_retries", 3) + + self.contact_latency_min = config.getint("contact_latency_min", 0) + self.contact_sensitivity = config.getint("contact_sensitivity", 0) + + self.trsync_timeout = config.getfloat( + "trsync_timeout", TRSYNC_TIMEOUT_DEFAULT, maxval=0.25 + ) + + self.skip_firmware_version_check = config.getboolean( + "skip_firmware_version_check", False + ) + + # Load models + self.model = None + self.models = {} + self.model_temp_builder = BeaconTempModelBuilder.load(config) + self.model_temp = None + self.fmin = None + self.default_model_name = config.get("default_model_name", "default") + self.model_manager = ModelManager(self) + + # Temperature sensor integration + self.last_temp = 0 + self.last_mcu_temp = None + self.measured_min = 99999999.0 + self.measured_max = 0.0 + self.mcu_temp = None + self.thermistor = None + + self.last_sample = None + self.last_received_sample = None + self.last_z_result = 0 + self.last_probe_position = (0, 0) + self.last_probe_result = None + self.last_offset_result = None + self.last_poke_result = None + self.last_contact_msg = None + self.hardware_failure = None + + self.mesh_helper = BeaconMeshHelper.create(self, config) + self.homing_helper = BeaconHomingHelper.create(self, config) + self.accel_helper = None + self.accel_config = BeaconAccelConfig(config, sensor_id) + + self._stream_en = 0 + self._stream_timeout_timer = self.reactor.register_timer(self._stream_timeout) + self._stream_callbacks = {} + self._stream_latency_requests = {} + self._stream_buffer = [] + self._stream_buffer_count = 0 + self._stream_buffer_limit = STREAM_BUFFER_LIMIT_DEFAULT + self._stream_buffer_limit_new = self._stream_buffer_limit + self._stream_samples_queue = queue.Queue() + self._stream_flush_event = threading.Event() + self._log_stream = None + self._data_filter = AlphaBetaFilter( + config.getfloat("filter_alpha", 0.5), + config.getfloat("filter_beta", 0.000001), + ) + self.trapq = None + self.mod_axis_twist_comp = None + self.get_z_compensation_value = lambda pos: 0.0 + + mainsync = printer.lookup_object("mcu")._clocksync + self._mcu = MCU(config, SecondarySync(self.reactor, mainsync)) + orig_stats = self._mcu.stats + + def beacon_mcu_stats(eventtime): + show, value = orig_stats(eventtime) + value += " " + self._extend_stats() + return show, value + + self._mcu.stats = beacon_mcu_stats + printer.add_object("mcu " + self.name, self._mcu) + self.cmd_queue = self._mcu.alloc_command_queue() + self._endstop_shared = BeaconEndstopShared(self) + self.mcu_probe = BeaconEndstopWrapper(self) + self.mcu_contact_probe = BeaconContactEndstopWrapper(self, config) + self._current_probe = "proximity" + + self.beacon_stream_cmd = None + self.beacon_set_threshold = None + self.beacon_home_cmd = None + self.beacon_stop_home_cmd = None + self.beacon_nvm_read_cmd = None + self.beacon_contact_home_cmd = None + self.beacon_contact_query_cmd = None + self.beacon_contact_stop_home_cmd = None + self.beacon_contact_set_latency_min_cmd = None + self.beacon_contact_set_sensitivity_cmd = None + + # Register z_virtual_endstop + register_as_probe = config.getboolean( + "register_as_probe", sensor_id.is_unnamed() + ) + if register_as_probe: + printer.lookup_object("pins").register_chip("probe", self) + self.printer.add_object("probe", BeaconProbeWrapper(self)) + + # Register event handlers + printer.register_event_handler("klippy:connect", self._handle_connect) + printer.register_event_handler("klippy:shutdown", self.force_stop_streaming) + self._mcu.register_config_callback(self._build_config) + self.compat_mcu_register_response( + self._handle_beacon_data, + "beacon_data samples=%c start_clock=%u delta_clock=%u data=%*s", + ) + self.compat_mcu_register_response( + self._handle_beacon_status, + "beacon_status mcu_temp=%u supply_voltage=%u coil_temp=%u status=%u", + ) + self.compat_mcu_register_response( + self._handle_beacon_contact, + "beacon_contact armed_clock=%u trigger_clock=%u detect_clock=%u latency=%c error=%c", + ) + + # Register webhooks + self._api_dump = APIDumpHelper( + printer, + lambda: self.streaming_session(self._api_dump_callback, latency=50), + lambda stream: stream.stop(), + None, + ) + sensor_id.register_endpoint("beacon/status", self._handle_req_status) + sensor_id.register_endpoint("beacon/dump", self._handle_req_dump) + + # Register gcode commands + sensor_id.register_command( + "BEACON_STREAM", self.cmd_BEACON_STREAM, desc=self.cmd_BEACON_STREAM_help + ) + sensor_id.register_command( + "BEACON_QUERY", self.cmd_BEACON_QUERY, desc=self.cmd_BEACON_QUERY_help + ) + sensor_id.register_command( + "BEACON_CALIBRATE", + self.cmd_BEACON_CALIBRATE, + desc=self.cmd_BEACON_CALIBRATE_help, + ) + sensor_id.register_command( + "BEACON_ESTIMATE_BACKLASH", + self.cmd_BEACON_ESTIMATE_BACKLASH, + desc=self.cmd_BEACON_ESTIMATE_BACKLASH_help, + ) + prefixed_probe_commands = config.getboolean("prefixed_probe_commands", False) + probe_cmd_prefix = "BEACON_" if prefixed_probe_commands else "" + sensor_id.register_command( + probe_cmd_prefix + "PROBE", self.cmd_PROBE, desc=self.cmd_PROBE_help + ) + sensor_id.register_command( + probe_cmd_prefix + "PROBE_ACCURACY", + self.cmd_PROBE_ACCURACY, + desc=self.cmd_PROBE_ACCURACY_help, + ) + sensor_id.register_command( + probe_cmd_prefix + "Z_OFFSET_APPLY_PROBE", + self.cmd_Z_OFFSET_APPLY_PROBE, + desc=self.cmd_Z_OFFSET_APPLY_PROBE_help, + ) + sensor_id.register_command( + "BEACON_POKE", self.cmd_BEACON_POKE, desc=self.cmd_BEACON_POKE_help + ) + sensor_id.register_command( + "BEACON_AUTO_CALIBRATE", + self.cmd_BEACON_AUTO_CALIBRATE, + desc=self.cmd_BEACON_AUTO_CALIBRATE_help, + ) + sensor_id.register_command( + "BEACON_OFFSET_COMPARE", + self.cmd_BEACON_OFFSET_COMPARE, + desc=self.cmd_BEACON_OFFSET_COMPARE_help, + ) + if sensor_id.is_unnamed(): + self._hook_probing_gcode(config, "z_tilt", "Z_TILT_ADJUST") + self._hook_probing_gcode(config, "quad_gantry_level", "QUAD_GANTRY_LEVEL") + self._hook_probing_gcode(config, "screws_tilt_adjust", "SCREWS_TILT_ADJUST") + self._hook_probing_gcode(config, "delta_calibrate", "DELTA_CALIBRATE") + + # Qidi Klipper compatibility + self.vibrate = 0 + + # Event handlers + + def _handle_connect(self): + self.phoming = self.printer.lookup_object("homing") + self.mod_axis_twist_comp = self.printer.lookup_object( + "axis_twist_compensation", None + ) + if self.mod_axis_twist_comp: + if hasattr(self.mod_axis_twist_comp, "get_z_compensation_value"): + self.get_z_compensation_value = lambda pos: ( + self.mod_axis_twist_comp.get_z_compensation_value(pos) + ) + elif hasattr(manual_probe, "ProbeResult"): + + def _update_compensation(pos): + cpos = [self.compat_create_probe_result(pos)] + bed_z = cpos[0].bed_z + self.mod_axis_twist_comp._update_z_compensation_value(cpos) + return cpos[0].bed_z - bed_z + + self.get_z_compensation_value = _update_compensation + else: + + def _update_compensation(pos): + cpos = list(pos) + self.mod_axis_twist_comp._update_z_compensation_value(cpos) + return cpos[2] - pos[2] + + self.get_z_compensation_value = _update_compensation + + if self.model is None: + self.model = self.models.get(self.default_model_name, None) + + def _check_mcu_version(self): + if self.skip_firmware_version_check: + return "" + updater = os.path.join(self.id.tracker.home_dir(), "update_firmware.py") + if not os.path.exists(updater): + logging.info( + "Could not find Beacon firmware update script, won't check for update." + ) + return "" + serialport = self.compat_serial_port(self._mcu) + + parent_conn, child_conn = multiprocessing.Pipe() + + def do(): + try: + output = subprocess.check_output( + [updater, "check", serialport], universal_newlines=True + ) + child_conn.send((False, output.strip())) + except Exception: + child_conn.send((True, traceback.format_exc())) + child_conn.close() + + child = multiprocessing.Process(target=do) + child.daemon = True + child.start() + eventtime = self.reactor.monotonic() + while child.is_alive(): + eventtime = self.reactor.pause(eventtime + 0.1) + is_err, result = parent_conn.recv() + child.join() + parent_conn.close() + if is_err: + logging.info("Executing Beacon update script failed: %s", result) + elif result != "": + self.gcode.respond_raw("!! " + result + "\n") + pconfig = self.printer.lookup_object("configfile") + try: + pconfig.runtime_warning(result) + except AttributeError: + logging.info(result) + return result + return "" + + def _build_config(self): + version_info = self._check_mcu_version() + + try: + self.beacon_stream_cmd = self._mcu.lookup_command( + "beacon_stream en=%u", cq=self.cmd_queue + ) + self.beacon_set_threshold = self._mcu.lookup_command( + "beacon_set_threshold trigger=%u untrigger=%u", cq=self.cmd_queue + ) + self.beacon_home_cmd = self._mcu.lookup_command( + "beacon_home trsync_oid=%c trigger_reason=%c trigger_invert=%c", + cq=self.cmd_queue, + ) + self.beacon_stop_home_cmd = self._mcu.lookup_command( + "beacon_stop_home", cq=self.cmd_queue + ) + self.beacon_nvm_read_cmd = self._mcu.lookup_query_command( + "beacon_nvm_read len=%c offset=%hu", + "beacon_nvm_data bytes=%*s offset=%hu", + cq=self.cmd_queue, + ) + self.beacon_contact_home_cmd = self._mcu.lookup_command( + "beacon_contact_home trsync_oid=%c trigger_reason=%c trigger_type=%c", + cq=self.cmd_queue, + ) + self.beacon_contact_query_cmd = self._mcu.lookup_query_command( + "beacon_contact_query", + "beacon_contact_state triggered=%c detect_clock=%u", + cq=self.cmd_queue, + ) + self.beacon_contact_stop_home_cmd = self._mcu.lookup_command( + "beacon_contact_stop_home", + cq=self.cmd_queue, + ) + try: + self.beacon_contact_set_latency_min_cmd = self._mcu.lookup_command( + "beacon_contact_set_latency_min latency_min=%c", + cq=self.cmd_queue, + ) + except msgproto.error: + pass + try: + self.beacon_contact_set_sensitivity_cmd = self._mcu.lookup_command( + "beacon_contact_set_sensitivity sensitivity=%c", + cq=self.cmd_queue, + ) + except msgproto.error: + pass + + constants = self._mcu.get_constants() + + self._mcu_freq = self._mcu.get_constant_float("CLOCK_FREQ") + + self.inv_adc_max = 1.0 / constants.get("ADC_MAX") + self.temp_smooth_count = constants.get("BEACON_ADC_SMOOTH_COUNT") + self.thermistor = thermistor.Thermistor(10000.0, 0.0) + self.thermistor.setup_coefficients_beta(25.0, 47000.0, 4101.0) + + self.toolhead = self.printer.lookup_object("toolhead") + self.kinematics = self.toolhead.get_kinematics() + self.trapq = self.toolhead.get_trapq() + + self.mcu_temp = BeaconMCUTempHelper.build_with_nvm(self) + self.model_temp = self.model_temp_builder.build_with_nvm(self) + if self.model_temp: + self.fmin = self.model_temp.fmin + if self.model is None: + self.model = self.models.get(self.default_model_name, None) + if self.model: + self._apply_threshold() + + if self.beacon_stream_cmd is not None: + self.beacon_stream_cmd.send([1 if self._stream_en else 0]) + if self._stream_en: + curtime = self.reactor.monotonic() + self.reactor.update_timer( + self._stream_timeout_timer, curtime + STREAM_TIMEOUT + ) + else: + self.reactor.update_timer( + self._stream_timeout_timer, self.reactor.NEVER + ) + + if constants.get("BEACON_HAS_ACCEL", 0) == 1: + logging.info("Enabling Beacon accelerometer") + if self.accel_helper is None: + self.accel_helper = BeaconAccelHelper( + self, self.accel_config, constants + ) + else: + self.accel_helper.reinit(constants) + + except msgproto.error as e: + if version_info != "": + raise msgproto.error(version_info + "\n\n" + str(e)) + raise + + def _extend_stats(self): + parts = [ + "coil_temp=%.1f" % (self.last_temp,), + "refs=%d" % (self._stream_en,), + ] + if self.last_mcu_temp is not None: + mcu_temp, supply_voltage = self.last_mcu_temp + parts.append("mcu_temp=%.2f" % (mcu_temp,)) + parts.append("supply_voltage=%.3f" % (supply_voltage,)) + + return " ".join(parts) + + def _api_dump_callback(self, sample): + tmp = [sample.get(key, None) for key in API_DUMP_FIELDS] + self._api_dump.buffer.append(tmp) + + # Virtual endstop + + def setup_pin(self, pin_type, pin_params): + if pin_type != "endstop" or pin_params["pin"] != "z_virtual_endstop": + raise pins.error("Probe virtual endstop only useful as endstop pin") + if pin_params["invert"] or pin_params["pullup"]: + raise pins.error("Can not pullup/invert probe virtual endstop") + return self.mcu_probe + + # Probe interface + + def multi_probe_begin(self): + self._start_streaming() + + def multi_probe_end(self): + self._stop_streaming() + + def get_offsets(self, _gcmd=None): + if self._current_probe == "contact": + return 0, 0, 0 + else: + return self.x_offset, self.y_offset, self.trigger_distance + + def get_lift_speed(self, gcmd=None): + if gcmd is not None: + return gcmd.get_float("LIFT_SPEED", self.lift_speed, above=0.0) + return self.lift_speed + + def run_probe(self, gcmd): + method = gcmd.get("PROBE_METHOD", self.default_probe_method).lower() + self._current_probe = method + if method == "proximity": + return self._run_probe_proximity(gcmd) + elif method == "contact": + self._start_streaming() + try: + return self._run_probe_contact(gcmd) + finally: + self._stop_streaming() + else: + raise gcmd.error("Invalid PROBE_METHOD, valid choices: proximity, contact") + + def _move_to_probing_height(self, speed): + target = self.trigger_distance + top = target + self.backlash_comp + cur_z = self.toolhead.get_position()[2] + if cur_z < top: + self.toolhead.manual_move([None, None, top], speed) + self.toolhead.manual_move([None, None, target], speed) + self.toolhead.wait_moves() + + def _run_probe_proximity(self, gcmd): + if self.model is None: + raise self.printer.command_error("No Beacon model loaded") + + speed = gcmd.get_float("PROBE_SPEED", self.speed, above=0.0) + allow_faulty = gcmd.get_int("ALLOW_FAULTY_COORDINATE", 0) != 0 + toolhead = self.printer.lookup_object("toolhead") + curtime = self.reactor.monotonic() + if "z" not in toolhead.get_status(curtime)["homed_axes"]: + raise self.printer.command_error("Must home before probe") + + self._start_streaming() + try: + return self._probe(speed, allow_faulty=allow_faulty) + finally: + self._stop_streaming() + + def _probing_move_to_probing_height(self, speed): + curtime = self.reactor.monotonic() + status = self.kinematics.get_status(curtime) + pos = self.toolhead.get_position() + pos[2] = status["axis_minimum"][2] + try: + self.phoming.probing_move(self.mcu_probe, pos, speed) + except self.printer.command_error as e: + reason = str(e) + if "Timeout during probing move" in reason: + reason += probe.HINT_TIMEOUT + raise self.printer.command_error(reason) + + def _probe(self, speed, num_samples=10, allow_faulty=False): + target = self.trigger_distance + tdt = self.trigger_dive_threshold + dist, samples = self._sample(5, num_samples) + + x, y = samples[0]["pos"][0:2] + if self._is_faulty_coordinate(x, y, True): + msg = "Probing within a faulty area" + if not allow_faulty: + raise self.printer.command_error(msg) + else: + self.gcode.respond_raw("!! " + msg + "\n") + + if dist > target + tdt: + # If we are above the dive threshold right now, we'll need to + # do probing move and then re-measure + self._probing_move_to_probing_height(speed) + dist, samples = self._sample(self.z_settling_time, num_samples) + elif math.isinf(dist) and dist < 0: + # We were below the valid range of the model + msg = "Attempted to probe with Beacon below calibrated model range" + raise self.printer.command_error(msg) + elif self.toolhead.get_position()[2] < target - tdt: + # We are below the probing target height, we'll move to the + # correct height and take a new sample. + self._move_to_probing_height(speed) + dist, samples = self._sample(self.z_settling_time, num_samples) + + pos = samples[0]["pos"] + + self.gcode.respond_info( + "probe at %.3f,%.3f,%.3f is z=%.6f" % (pos[0], pos[1], pos[2], dist) + ) + + return [pos[0], pos[1], pos[2] + target - dist] + + def _run_probe_contact(self, gcmd): + self.toolhead.wait_moves() + speed = gcmd.get_float( + "PROBE_SPEED", self.autocal_speed, above=0.0, maxval=self.autocal_max_speed + ) + lift_speed = self.get_lift_speed(gcmd) + sample_count = gcmd.get_int("SAMPLES", self.autocal_sample_count, minval=1) + retract_dist = gcmd.get_float( + "SAMPLE_RETRACT_DIST", self.autocal_retract_dist, minval=1 + ) + tolerance = gcmd.get_float( + "SAMPLES_TOLERANCE", self.autocal_tolerance, above=0.0 + ) + max_retries = gcmd.get_int( + "SAMPLES_TOLERANCE_RETRIES", self.autocal_max_retries, minval=0 + ) + samples_result = gcmd.get("SAMPLES_RESULT", "mean") + drop_n = gcmd.get_int("SAMPLES_DROP", 0, minval=0) + retries = 0 + samples = [] + + posxy = self.toolhead.get_position()[:2] + + self.mcu_contact_probe.activate_gcode.run_gcode_from_command() + try: + while len(samples) < sample_count: + pos = self._probe_contact(speed) + self.toolhead.manual_move(posxy + [pos[2] + retract_dist], lift_speed) + if drop_n > 0: + drop_n -= 1 + continue + samples.append(pos[2]) + spread = max(samples) - min(samples) + if spread > tolerance: + if retries >= max_retries: + raise gcmd.error("Probe samples exceed sample_tolerance") + gcmd.respond_info("Probe samples exceed tolerance. Retrying...") + samples = [] + retries += 1 + if samples_result == "median": + return posxy + [median(samples)] + else: + return posxy + [float(np.mean(samples))] + finally: + self.mcu_contact_probe.deactivate_gcode.run_gcode_from_command() + + def _probe_contact(self, speed): + self.toolhead.get_last_move_time() + self._sample_async() + start_pos = self.toolhead.get_position() + hmove = HomingMove(self.printer, [(self.mcu_contact_probe, "contact")]) + pos = start_pos[:] + pos[2] = -2 + try: + epos = hmove.homing_move(pos, speed, probe_pos=True)[:3] + except self.printer.command_error as e: + if self.printer.is_shutdown(): + reason = "Probing failed due to printer shutdown" + else: + reason = str(e) + if "Timeout during probing move" in reason: + reason += probe.HINT_TIMEOUT + raise self.printer.command_error(reason) + epos[2] += self.get_z_compensation_value(pos) + self.gcode.respond_info( + "probe at %.3f,%.3f is z=%.6f" % (epos[0], epos[1], epos[2]) + ) + return epos[:3] + + # Accelerometer interface + + def start_internal_client(self): + if not self.accel_helper: + msg = "This Beacon has no accelerometer" + raise self.printer.command_error(msg) + return self.accel_helper.start_internal_client() + + # Calibration routines + + def _start_calibration(self, gcmd): + allow_faulty = gcmd.get_int("ALLOW_FAULTY_COORDINATE", 0) != 0 + nozzle_z = gcmd.get_float("NOZZLE_Z", self.cal_nozzle_z) + if gcmd.get("SKIP_MANUAL_PROBE", None) is not None: + kin = self.kinematics + kin_spos = { + s.get_name(): s.get_commanded_position() for s in kin.get_steppers() + } + kin_pos = kin.calc_position(kin_spos) + if self._is_faulty_coordinate(kin_pos[0], kin_pos[1]): + msg = "Calibrating within a faulty area" + if not allow_faulty: + raise gcmd.error(msg) + else: + gcmd.respond_raw("!! " + msg + "\n") + self._calibrate(gcmd, kin_pos, nozzle_z, False) + else: + curtime = self.printer.get_reactor().monotonic() + kin_status = self.toolhead.get_status(curtime) + if "xy" not in kin_status["homed_axes"]: + raise self.printer.command_error("Must home X and Y before calibration") + + kin_pos = self.toolhead.get_position() + if self._is_faulty_coordinate(kin_pos[0], kin_pos[1]): + msg = "Calibrating within a faulty area" + if not allow_faulty: + raise gcmd.error(msg) + else: + gcmd.respond_raw("!! " + msg + "\n") + + forced_z = False + if "z" not in kin_status["homed_axes"]: + self.toolhead.get_last_move_time() + pos = self.toolhead.get_position() + pos[2] = ( + kin_status["axis_maximum"][2] + - 2.0 + - gcmd.get_float("CEIL", self.cal_ceil) + ) + self.compat_toolhead_set_position_homing_z(self.toolhead, pos) + forced_z = True + + def cb(kin_pos): + return self._calibrate(gcmd, kin_pos, nozzle_z, forced_z) + + manual_probe.ManualProbeHelper(self.printer, gcmd, cb) + + def _calibrate(self, gcmd, kin_pos, cal_nozzle_z, forced_z, is_auto=False): + if kin_pos is None: + if forced_z: + kin = self.kinematics + self.compat_kin_note_z_not_homed(kin) + return + + gcmd.respond_info("Beacon calibration starting") + cal_floor = gcmd.get_float("FLOOR", self.cal_floor) + cal_ceil = gcmd.get_float("CEIL", self.cal_ceil) + cal_speed = gcmd.get_float("DESCEND_SPEED", self.cal_speed) + move_speed = gcmd.get_float("MOVE_SPEED", self.cal_move_speed) + model_name = gcmd.get("MODEL_NAME", "default") + + toolhead = self.toolhead + toolhead.wait_moves() + + # Move coordinate system to nozzle location + self.toolhead.get_last_move_time() + curpos = toolhead.get_position() + curpos[2] = cal_nozzle_z + toolhead.set_position(curpos) + + # Move over to probe coordinate and pull out backlash + curpos[2] = cal_ceil + self.backlash_comp + toolhead.manual_move(curpos, move_speed) # Up + curpos[0] -= self.x_offset + curpos[1] -= self.y_offset + toolhead.manual_move(curpos, move_speed) # Over + curpos[2] = cal_ceil + toolhead.manual_move(curpos, move_speed) # Down + toolhead.wait_moves() + + samples = [] + + def cb(sample): + samples.append(sample) + + # Descend while sampling + toolhead.flush_step_generation() + try: + self._start_streaming() + self._sample_printtime_sync(50) + with self.streaming_session(cb): + self._sample_printtime_sync(50) + toolhead.dwell(0.250) + curpos[2] = cal_floor + toolhead.manual_move(curpos, cal_speed) + toolhead.flush_step_generation() + self._sample_printtime_sync(50) + finally: + self._stop_streaming() + + # Fit the sampled data + z_offset = [s["pos"][2] for s in samples] + freq = [s["freq"] for s in samples] + temp = [s["temp"] for s in samples] + inv_freq = [1 / f for f in freq] + poly = Polynomial.fit(inv_freq, z_offset, 9) + temp_median = median(temp) + self.model = BeaconModel( + model_name, self, poly, temp_median, min(z_offset), max(z_offset) + ) + self.models[self.model.name] = self.model + self.model.save(self, not is_auto) + self._apply_threshold() + + # Dump calibration curve + fn = "/tmp/beacon-calibrate-" + time.strftime("%Y%m%d_%H%M%S") + ".csv" + with open(fn, "w") as f: + f.write("freq,z,temp\n") + for i in range(len(freq)): + f.write("%.5f,%.5f,%.3f\n" % (freq[i], z_offset[i], temp[i])) + + gcmd.respond_info( + "Beacon calibrated at %.3f,%.3f from " + "%.3f to %.3f, speed %.2f mm/s, temp %.2fC" + % (curpos[0], curpos[1], cal_floor, cal_ceil, cal_speed, temp_median) + ) + + # Internal + + def _update_thresholds(self, moving_up=False): + self.trigger_freq = self.dist_to_freq(self.trigger_distance, self.last_temp) + self.untrigger_freq = self.trigger_freq * (1 - self.trigger_hysteresis) + + def _apply_threshold(self, moving_up=False): + self._update_thresholds() + trigger_c = int(self.freq_to_count(self.trigger_freq)) + untrigger_c = int(self.freq_to_count(self.untrigger_freq)) + if self.beacon_set_threshold is not None: + self.beacon_set_threshold.send([trigger_c, untrigger_c]) + + def _register_model(self, name, model): + if name in self.models: + raise self.printer.config_error( + "Multiple Beacon models with samename '%s'" % (name,) + ) + self.models[name] = model + + def _is_faulty_coordinate(self, x, y, add_offsets=False): + if not self.mesh_helper: + return False + return self.mesh_helper._is_faulty_coordinate(x, y, add_offsets) + + def _handle_beacon_status(self, params): + if self.mcu_temp is not None: + self.last_mcu_temp = self.mcu_temp.compensate( + self, params["mcu_temp"], params["supply_voltage"] + ) + if self.thermistor is not None: + self.last_temp = self.thermistor.calc_temp( + params["coil_temp"] / self.temp_smooth_count * self.inv_adc_max + ) + + def _handle_beacon_contact(self, params): + self.last_contact_msg = params + + def _hook_probing_gcode(self, config, module, cmd): + if not config.has_section(module): + return + section = config.getsection(module) + mod = self.printer.load_object(section, module) + if mod is None: + return + orig = self.gcode.register_command(cmd, None) + + def cb(gcmd): + self._current_probe = gcmd.get( + "PROBE_METHOD", self.default_probe_method + ).lower() + return orig(gcmd) + + self.gcode.register_command(cmd, cb) + + # Streaming mode + + def _check_hardware(self, sample): + if not self.hardware_failure: + msg = None + if sample["data"] == 0xFFFFFFF: + msg = "coil is shorted or not connected" + elif self.fmin is not None and sample["freq"] > 1.35 * self.fmin: + msg = "coil expected max frequency exceeded" + if msg: + msg = "Beacon hardware issue: " + msg + self.hardware_failure = msg + logging.error(msg) + if self._stream_en: + self.printer.invoke_shutdown(msg) + else: + self.gcode.respond_raw("!! " + msg + "\n") + elif self._stream_en: + self.printer.invoke_shutdown(self.hardware_failure) + + def _clock32_to_time(self, clock): + clock64 = self._mcu.clock32_to_clock64(clock) + return self._mcu.clock_to_print_time(clock64) + + def _start_streaming(self): + if self._stream_en == 0 and self.beacon_stream_cmd is not None: + self.beacon_stream_cmd.send([1]) + curtime = self.reactor.monotonic() + self.reactor.update_timer( + self._stream_timeout_timer, curtime + STREAM_TIMEOUT + ) + self._stream_en += 1 + self._data_filter.reset() + self._stream_flush() + + def _stop_streaming(self): + self._stream_en -= 1 + if self._stream_en == 0: + self.reactor.update_timer(self._stream_timeout_timer, self.reactor.NEVER) + if self.beacon_stream_cmd is not None: + self.beacon_stream_cmd.send([0]) + self._stream_flush() + + def force_stop_streaming(self): + self.reactor.update_timer(self._stream_timeout_timer, self.reactor.NEVER) + if self.beacon_stream_cmd is not None: + self.beacon_stream_cmd.send([0]) + self._stream_flush() + + def _stream_timeout(self, eventtime): + if self._stream_flush(): + return eventtime + STREAM_TIMEOUT + if not self._stream_en: + return self.reactor.NEVER + if not self.printer.is_shutdown(): + msg = "Beacon sensor not receiving data" + logging.error(msg) + self.printer.invoke_shutdown(msg) + return self.reactor.NEVER + + def request_stream_latency(self, latency): + next_key = 0 + if self._stream_latency_requests: + next_key = max(self._stream_latency_requests.keys()) + 1 + new_limit = STREAM_BUFFER_LIMIT_DEFAULT + self._stream_latency_requests[next_key] = latency + min_requested = min(self._stream_latency_requests.values()) + if min_requested < new_limit: + new_limit = min_requested + if new_limit < 1: + new_limit = 1 + self._stream_buffer_limit_new = new_limit + return next_key + + def drop_stream_latency_request(self, key): + self._stream_latency_requests.pop(key, None) + new_limit = STREAM_BUFFER_LIMIT_DEFAULT + if self._stream_latency_requests: + min_requested = min(self._stream_latency_requests.values()) + if min_requested < new_limit: + new_limit = min_requested + if new_limit < 1: + new_limit = 1 + self._stream_buffer_limit_new = new_limit + + def streaming_session(self, callback, completion_callback=None, latency=None): + return StreamingHelper(self, callback, completion_callback, latency) + + def _stream_flush_message(self, msg): + last = None + for sample in msg: + clock, data = sample + temp = self.last_temp + if self.model_temp is not None and not (-40 < temp < 180): + msg = ( + "Beacon temperature sensor faulty(read %.2f C)," + " disabling temperature compensation" % (temp,) + ) + logging.error(msg) + self.gcode.respond_raw("!! " + msg + "\n") + self.model_temp = None + if temp: + self.measured_min = min(self.measured_min, temp) + self.measured_max = max(self.measured_max, temp) + + clock = self._mcu.clock32_to_clock64(clock) + time = self._mcu.clock_to_print_time(clock) + self._data_filter.update(time, data) + data_smooth = self._data_filter.value() + freq = self.count_to_freq(data_smooth) + dist = self.freq_to_dist(freq, temp) + pos = self._get_position_at_time(time) + if pos is not None: + if dist is not None: + dist -= self.get_z_compensation_value(pos) + last = sample = { + "temp": temp, + "clock": clock, + "time": time, + "data": data, + "data_smooth": data_smooth, + "freq": freq, + "dist": dist, + } + if pos is not None: + sample["pos"] = pos + self._check_hardware(sample) + + if len(self._stream_callbacks) > 0: + for cb in list(self._stream_callbacks.values()): + cb(sample) + if last is not None: + last = last.copy() + dist = last["dist"] + if dist is None or np.isinf(dist) or np.isnan(dist): + del last["dist"] + self.last_received_sample = last + + def _stream_flush(self): + self._stream_flush_event.clear() + updated_timer = False + while True: + try: + samples = self._stream_samples_queue.get_nowait() + updated_timer = False + for sample in samples: + if not updated_timer: + curtime = self.reactor.monotonic() + self.reactor.update_timer( + self._stream_timeout_timer, curtime + STREAM_TIMEOUT + ) + updated_timer = True + self._stream_flush_message(sample) + except queue.Empty: + return updated_timer + + def _stream_flush_schedule(self): + force = self._stream_en == 0 # When streaming is disabled, let all through + if self._stream_buffer_limit_new != self._stream_buffer_limit: + force = True + self._stream_buffer_limit = self._stream_buffer_limit_new + if not force and self._stream_buffer_count < self._stream_buffer_limit: + return + self._stream_samples_queue.put_nowait(self._stream_buffer) + self._stream_buffer = [] + self._stream_buffer_count = 0 + if self._stream_flush_event.is_set(): + return + self._stream_flush_event.set() + self.reactor.register_async_callback(lambda e: self._stream_flush()) + + def _handle_beacon_data(self, params): + if self.trapq is None: + return + + buf = bytearray(params["data"]) + sample_count = params["samples"] + start_clock = params["start_clock"] + delta_clock = ( + params["delta_clock"] / (sample_count - 1) if sample_count > 1 else 0 + ) + + samples = [] + data = 0 + for i in range(0, sample_count): + if buf[0] & 0x80 == 0: + delta = ((buf[0] & 0x7F) << 8) + buf[1] + data = data + delta - ((buf[0] & 0x40) << 9) + buf = buf[2:] + else: + data = (buf[0] & 0x7F) << 24 | buf[1] << 16 | buf[2] << 8 | buf[3] + buf = buf[4:] + clock = start_clock + int(round(i * delta_clock)) + samples.append((clock, data)) + + self._stream_buffer.append(samples) + self._stream_buffer_count += len(samples) + self._stream_flush_schedule() + + def _get_position_at_time(self, print_time): + kin = self.kinematics + pos = { + s.get_name(): s.mcu_to_commanded_position( + s.get_past_mcu_position(print_time) + ) + for s in kin.get_steppers() + } + return kin.calc_position(pos) + + def _sample_printtime_sync(self, skip=0, count=1): + move_time = self.toolhead.get_last_move_time() + settle_clock = self._mcu.print_time_to_clock(move_time) + samples = [] + total = skip + count + + def cb(sample): + if sample["clock"] >= settle_clock: + samples.append(sample) + if len(samples) >= total: + raise StopStreaming + + with self.streaming_session(cb, latency=skip + count) as ss: + ss.wait() + + samples = samples[skip:] + + if count == 1: + return samples[0] + else: + return samples + + def _sample(self, skip, count): + samples = self._sample_printtime_sync(skip, count) + return (median([s["dist"] for s in samples]), samples) + + def _sample_async(self, count=1): + samples = [] + + def cb(sample): + samples.append(sample) + if len(samples) >= count: + raise StopStreaming + + with self.streaming_session(cb, latency=count) as ss: + ss.wait() + + if count == 1: + return samples[0] + else: + return samples + + def count_to_freq(self, count): + return count * self._mcu_freq / (2**28) + + def freq_to_count(self, freq): + return freq * (2**28) / self._mcu_freq + + def dist_to_freq(self, dist, temp): + if self.model is None: + return None + return self.model.dist_to_freq(dist, temp) + + def freq_to_dist(self, freq, temp): + if self.model is None: + return None + return self.model.freq_to_dist(freq, temp) + + def get_status(self, eventtime): + model = None + if self.model is not None: + model = self.model.name + return { + "last_sample": self.last_sample, + "last_received_sample": self.last_received_sample, + "last_z_result": self.last_z_result, + "last_probe_position": self.last_probe_position, + "last_probe_result": self.last_probe_result, + "last_offset_result": self.last_offset_result, + "last_poke_result": self.last_poke_result, + "model": model, + } + + # Webhook handlers + + def _handle_req_status(self, web_request): + temp = None + sample = self._sample_async() + out = { + "freq": sample["freq"], + "dist": sample["dist"], + } + temp = sample["temp"] + if temp is not None: + out["temp"] = temp + web_request.send(out) + + def _handle_req_dump(self, web_request): + self._api_dump.add_web_client(web_request) + web_request.send({"header": API_DUMP_FIELDS}) + + # Compat wrappers + + def compat_toolhead_set_position_homing_z(self, toolhead, pos): + func = toolhead.set_position + kind = tuple + if hasattr(func, "__defaults__"): # Python 3 + kind = type(func.__defaults__[0]) + else: # Python 2 + kind = type(func.func_defaults[0]) + if kind is str: + return toolhead.set_position(pos, homing_axes="z") + else: + return toolhead.set_position(pos, homing_axes=[2]) + + def compat_kin_note_z_not_homed(self, kin): + if hasattr(kin, "note_z_not_homed"): + kin.note_z_not_homed() + elif hasattr(kin, "clear_homing_state"): + kin.clear_homing_state("z") + + def compat_serial_port(self, mcu): + if hasattr(mcu, "_serialport"): + return mcu._serialport + elif hasattr(mcu, "_conn_helper"): + return mcu._conn_helper.get_serialport()[0] + else: + raise Exception("Could not determine serial port") + + probe_result_builder = None + + def compat_create_probe_result(self, test_pos): + if BeaconProbe.probe_result_builder is None: + if hasattr(manual_probe, "ProbeResult"): + BeaconProbe.probe_result_builder = prb_proberesult + else: + BeaconProbe.probe_result_builder = prb_identity + return BeaconProbe.probe_result_builder(self, test_pos) + + def compat_mcu_register_response(self, cb, msg, oid=None): + if hasattr(self._mcu, "register_serial_response"): + return self._mcu.register_serial_response(cb, msg, oid) + else: + name = msg.split()[0] + return self._mcu.register_response(cb, name, oid) + + # GCode command handlers + + cmd_PROBE_help = "Probe Z-height at current XY position" + + def cmd_PROBE(self, gcmd): + self.last_probe_result = "failed" + pos = self.run_probe(gcmd) + gcmd.respond_info("Result is z=%.6f" % (pos[2],)) + offset = self.get_offsets() + self.last_z_result = pos[2] - offset[2] + self.last_probe_position = (pos[0] - offset[0], pos[1] - offset[1]) + self.last_probe_result = "ok" + + cmd_BEACON_CALIBRATE_help = "Calibrate beacon response curve" + + def cmd_BEACON_CALIBRATE(self, gcmd): + self._start_calibration(gcmd) + + cmd_BEACON_ESTIMATE_BACKLASH_help = "Estimate Z axis backlash" + + def cmd_BEACON_ESTIMATE_BACKLASH(self, gcmd): + # Get to correct Z height + overrun = gcmd.get_float("OVERRUN", 1.0) + speed = gcmd.get_float("PROBE_SPEED", self.speed, above=0.0) + cur_z = self.toolhead.get_position()[2] + self.toolhead.manual_move([None, None, cur_z + overrun], speed) + self.run_probe(gcmd) + + lift_speed = self.get_lift_speed(gcmd) + target = gcmd.get_float("Z", self.trigger_distance) + + num_samples = gcmd.get_int("SAMPLES", 20) + wait = self.z_settling_time + + samples_up = [] + samples_down = [] + + next_dir = -1 + + try: + self._start_streaming() + + cur_dist, _samples = self._sample(wait, 10) + pos = self.toolhead.get_position() + missing = target - cur_dist + target = pos[2] + missing + gcmd.respond_info("Target kinematic Z is %.3f" % (target,)) + + if target - overrun < 0: + raise gcmd.error("Target minus overrun must exceed 0mm") + + while len(samples_up) + len(samples_down) < num_samples: + liftpos = [None, None, target + overrun * next_dir] + self.toolhead.manual_move(liftpos, lift_speed) + liftpos = [None, None, target] + self.toolhead.manual_move(liftpos, lift_speed) + self.toolhead.wait_moves() + dist, _samples = self._sample(wait, 10) + {-1: samples_up, 1: samples_down}[next_dir].append(dist) + next_dir = next_dir * -1 + + finally: + self._stop_streaming() + + res_up = median(samples_up) + res_down = median(samples_down) + + gcmd.respond_info( + "Median distance moving up %.5f, down %.5f, " + "delta %.5f over %d samples" + % (res_up, res_down, res_down - res_up, num_samples) + ) + + cmd_BEACON_QUERY_help = "Take a sample from the sensor" + + def cmd_BEACON_QUERY(self, gcmd): + sample = self._sample_async() + last_value = sample["freq"] + dist = sample["dist"] + temp = sample["temp"] + self.last_sample = { + "time": sample["time"], + "value": last_value, + "temp": temp, + "dist": None if dist is None or np.isinf(dist) or np.isnan(dist) else dist, + } + if dist is None: + gcmd.respond_info( + "Last reading: %.2fHz, %.2fC, no model" + % ( + last_value, + temp, + ) + ) + else: + gcmd.respond_info( + "Last reading: %.2fHz, %.2fC, %.5fmm" % (last_value, temp, dist) + ) + + cmd_BEACON_STREAM_help = "Enable Beacon Streaming" + + def cmd_BEACON_STREAM(self, gcmd): + if self._log_stream is not None: + self._log_stream.stop() + self._log_stream = None + gcmd.respond_info("Beacon Streaming disabled") + else: + f = None + completion_cb = None + fn = gcmd.get("FILENAME") + f = open(fn, "w") + + def close_file(): + f.close() + + completion_cb = close_file + f.write("time,data,data_smooth,freq,dist,temp,pos_x,pos_y,pos_z\n") + + def cb(sample): + pos = sample.get("pos", None) + obj = "%.4f,%d,%.2f,%.5f,%.5f,%.2f,%s,%s,%s\n" % ( + sample["time"], + sample["data"], + sample["data_smooth"], + sample["freq"], + sample["dist"], + sample["temp"], + "%.3f" % (pos[0],) if pos is not None else "", + "%.3f" % (pos[1],) if pos is not None else "", + "%.3f" % (pos[2],) if pos is not None else "", + ) + f.write(obj) + + self._log_stream = self.streaming_session(cb, completion_cb) + gcmd.respond_info("Beacon Streaming enabled") + + cmd_PROBE_ACCURACY_help = "Probe Z-height accuracy at current XY position" + + def cmd_PROBE_ACCURACY(self, gcmd): + speed = gcmd.get_float("PROBE_SPEED", self.speed, above=0.0) + lift_speed = self.get_lift_speed(gcmd) + sample_count = gcmd.get_int("SAMPLES", 10, minval=1) + sample_retract_dist = gcmd.get_float("SAMPLE_RETRACT_DIST", 0) + allow_faulty = gcmd.get_int("ALLOW_FAULTY_COORDINATE", 0) != 0 + pos = self.toolhead.get_position() + gcmd.respond_info( + "PROBE_ACCURACY at X:%.3f Y:%.3f Z:%.3f" + " (samples=%d retract=%.3f" + " speed=%.1f lift_speed=%.1f)\n" + % ( + pos[0], + pos[1], + pos[2], + sample_count, + sample_retract_dist, + speed, + lift_speed, + ) + ) + + start_height = self.trigger_distance + sample_retract_dist + liftpos = [None, None, start_height] + self.toolhead.manual_move(liftpos, lift_speed) + + self.multi_probe_begin() + positions = [] + while len(positions) < sample_count: + pos = self._probe(speed, allow_faulty=allow_faulty) + positions.append(pos) + self.toolhead.manual_move(liftpos, lift_speed) + self.multi_probe_end() + + zs = [p[2] for p in positions] + max_value = max(zs) + min_value = min(zs) + range_value = max_value - min_value + avg_value = sum(zs) / len(positions) + median_ = median(zs) + + deviation_sum = 0 + for i in range(len(zs)): + deviation_sum += pow(zs[2] - avg_value, 2.0) + sigma = (deviation_sum / len(zs)) ** 0.5 + + gcmd.respond_info( + "probe accuracy results: maximum %.6f, minimum %.6f, range %.6f, " + "average %.6f, median %.6f, standard deviation %.6f" + % (max_value, min_value, range_value, avg_value, median_, sigma) + ) + + cmd_Z_OFFSET_APPLY_PROBE_help = "Adjust the probe's z_offset" + + def cmd_Z_OFFSET_APPLY_PROBE(self, gcmd): + gcode_move = self.printer.lookup_object("gcode_move") + offset = gcode_move.get_status()["homing_origin"].z + + if offset == 0: + self.gcode.respond_info("Nothing to do: Z Offset is 0") + return + + if not self.model: + raise self.gcode.error( + "You must calibrate your model first, use BEACON_CALIBRATE." + ) + + # We use the model code to save the new offset, but we can't actually + # apply that offset yet because the gcode_offset is still in effect. + # If the user continues to do stuff after this, the newly set model + # offset would compound with the gcode offset. To ensure this doesn't + # happen, we revert to the old model offset afterwards. + # Really, the user should just be calling `SAVE_CONFIG` now. + old_offset = self.model.offset + self.model.offset += offset + self.model.save(self, False) + gcmd.respond_info( + "Beacon model offset has been updated, new value is %.5f\n" + "You must run the SAVE_CONFIG command now to update the\n" + "printer config file and restart the printer." % (self.model.offset,) + ) + self.model.offset = old_offset + + cmd_BEACON_POKE_help = "Poke the bed" + + def cmd_BEACON_POKE(self, gcmd): + top = gcmd.get_float("TOP", 5) + bottom = gcmd.get_float("BOTTOM", -0.3) + speed = gcmd.get_float("SPEED", 3, maxval=self.autocal_max_speed) + + pos = self.toolhead.get_position() + gcmd.respond_info( + "Poke test at (%.3f,%.3f), from %.3f to %.3f, at %.3f mm/s" + % (pos[0], pos[1], top, bottom, speed) + ) + + self.last_probe_result = "failed" + self.toolhead.manual_move([None, None, top], 100.0) + self.toolhead.wait_moves() + self.toolhead.dwell(0.5) + + ts = time.strftime("%Y%m%d_%H%M%S") + fn = "/tmp/poke_%s_%.3f_%.3f-%.3f.csv" % (ts, speed, top, bottom) + with open(fn, "w") as f: + f.write("time,data,data_smooth,freq,dist,temp,pos_x,pos_y,pos_z\n") + + def cb(sample): + pos = sample.get("pos", None) + obj = "%.6f,%d,%.2f,%.5f,%.5f,%.2f,%s,%s,%s\n" % ( + sample["time"], + sample["data"], + sample["data_smooth"], + sample["freq"], + sample["dist"], + sample["temp"], + "%.3f" % (pos[0],) if pos is not None else "", + "%.3f" % (pos[1],) if pos is not None else "", + "%.5f" % (pos[2],) if pos is not None else "", + ) + f.write(obj) + + with self.streaming_session(cb): + self._sample_async() + self.toolhead.get_last_move_time() + pos = self.toolhead.get_position() + self.mcu_contact_probe.activate_gcode.run_gcode_from_command() + try: + hmove = HomingMove( + self.printer, [(self.mcu_contact_probe, "contact")] + ) + pos[2] = bottom + epos = hmove.homing_move(pos, speed, probe_pos=True)[:3] + self.toolhead.wait_moves() + spos = self.toolhead.get_position()[:3] + armpos = self._get_position_at_time( + self._clock32_to_time(self.last_contact_msg["armed_clock"]) + ) + gcmd.respond_info("Armed at: z=%.5f" % (armpos[2],)) + gcmd.respond_info( + "Triggered at: z=%.5f with latency=%d" + % (epos[2], self.last_contact_msg["latency"]) + ) + gcmd.respond_info( + "Overshoot: %.3f um" % ((epos[2] - spos[2]) * 1000.0,) + ) + self.last_probe_result = "ok" + self.last_poke_result = { + "target_position": pos, + "arming_z": armpos[2], + "trigger_z": epos[2], + "stopped_z": spos[2], + "latency": self.last_contact_msg["latency"], + "error": self.last_contact_msg["error"], + } + except self.printer.command_error: + if self.printer.is_shutdown(): + raise self.printer.command_error( + "Homing failed due to printer shutdown" + ) + raise + finally: + self.mcu_contact_probe.deactivate_gcode.run_gcode_from_command() + self.toolhead.manual_move([None, None, top], 100.0) + self.toolhead.wait_moves() + + cmd_BEACON_AUTO_CALIBRATE_help = "Automatically calibrates the Beacon probe" + + def cmd_BEACON_AUTO_CALIBRATE(self, gcmd): + speed = gcmd.get_float( + "SPEED", self.autocal_speed, above=0, maxval=self.autocal_max_speed + ) + desired_accel = gcmd.get_float("ACCEL", self.autocal_accel, minval=1) + retract_dist = gcmd.get_float("RETRACT", self.autocal_retract_dist, minval=1) + retract_speed = gcmd.get_float( + "RETRACT_SPEED", self.autocal_retract_speed, minval=1 + ) + sample_count = gcmd.get_int("SAMPLES", self.autocal_sample_count, minval=1) + tolerance = gcmd.get_float( + "SAMPLES_TOLERANCE", self.autocal_tolerance, above=0.0 + ) + max_retries = gcmd.get_int( + "SAMPLES_TOLERANCE_RETRIES", self.autocal_max_retries, minval=0 + ) + + curtime = self.reactor.monotonic() + kin = self.kinematics + kin_status = kin.get_status(curtime) + if "x" not in kin_status["homed_axes"] or "y" not in kin_status["homed_axes"]: + raise gcmd.error("Must home X and Y axes first") + + self.last_probe_result = "failed" + force_pos = self.toolhead.get_position()[:] + home_pos = force_pos[:] + amin, amax = kin_status["axis_minimum"][2], kin_status["axis_maximum"][2] + force_pos[2] = amax + home_pos[2] = amin + + stop_samples = [] + + old_max_accel = self.toolhead.get_status(curtime)["max_accel"] + gcode = self.printer.lookup_object("gcode") + + def set_max_accel(value): + gcode.run_script_from_command("SET_VELOCITY_LIMIT ACCEL=%.3f" % (value,)) + + homing_state = BeaconHomingState() + self.printer.send_event("homing:home_rails_begin", homing_state, []) + self.mcu_contact_probe.activate_gcode.run_gcode_from_command() + try: + self.compat_toolhead_set_position_homing_z(self.toolhead, force_pos) + skip_next = True + retries = 0 + while len(stop_samples) < sample_count: + if skip_next: + gcmd.respond_info("Initial approach") + else: + gcmd.respond_info( + "Collecting sample %d/%d" + % (len(stop_samples) + 1, sample_count) + ) + self.toolhead.wait_moves() + set_max_accel(desired_accel) + try: + hmove = HomingMove( + self.printer, [(self.mcu_contact_probe, "contact")] + ) + epos = hmove.homing_move(home_pos, speed, probe_pos=True) + except self.printer.command_error: + if self.printer.is_shutdown(): + raise self.printer.command_error( + "Homing failed due to printer shutdown" + ) + raise + finally: + set_max_accel(old_max_accel) + + retract_pos = self.toolhead.get_position()[:] + retract_pos[2] += retract_dist + if retract_pos[2] > amax: + retract_pos[2] = amax + self.toolhead.move(retract_pos, retract_speed) + self.toolhead.dwell(1.0) + + if not skip_next: + stop_samples.append(epos[2]) + mean = np.mean(stop_samples) + delta = max([abs(v - mean) for v in stop_samples]) + if delta > tolerance: + if retries >= max_retries: + raise gcmd.error( + "Sample spread too large(%.4f > %.4f)" + % (delta, tolerance) + ) + gcmd.respond_info( + "Sample spread too large(%.4f > %.4f), restarting" + % (delta, tolerance) + ) + retries += 1 + stop_samples = [] + skip_next = True + else: + skip_next = False + + gcmd.respond_info( + "Collected %d samples, %.4f sd" + % (len(stop_samples), np.std(stop_samples)) + ) + + current_delta = force_pos[2] - self.toolhead.get_position()[2] + true_zero_delta = force_pos[2] - np.mean(stop_samples) + + force_pos[2] = float(true_zero_delta - current_delta) + self.toolhead.set_position(force_pos) + + self.toolhead.wait_moves() + self.toolhead.flush_step_generation() + self.last_probe_result = "ok" + self.printer.send_event("homing:home_rails_end", homing_state, []) + if gcmd.get_int("SKIP_MODEL_CREATION", 0) == 0: + self._calibrate(gcmd, force_pos, force_pos[2], True, True) + + except self.printer.command_error: + self.compat_kin_note_z_not_homed(kin) + raise + finally: + self.mcu_contact_probe.deactivate_gcode.run_gcode_from_command() + + cmd_BEACON_OFFSET_COMPARE_help = ( + "Measures offset between contact and proximity measurements" + ) + + def cmd_BEACON_OFFSET_COMPARE(self, gcmd): + top = gcmd.get_float("TOP", 2) + + self.last_probe_result = "failed" + self.toolhead.get_last_move_time() + self._sample_async() + start_pos = self.toolhead.get_position() + + params = { + "SAMPLES_DROP": 1, + "SAMPLES": 3, + } + params.update(gcmd.get_command_parameters()) + + # Do contact move + epos = self._run_probe_contact( + self.gcode.create_gcode_command( + "PROBE", + "PROBE", + params, + ) + ) + + # Up + self.toolhead.manual_move([None, None, top + 0.5], 100.0) + + # Over + pos = start_pos[:2] + pos[0] -= self.x_offset + pos[1] -= self.y_offset + self.toolhead.manual_move(pos, 100.0) + self.toolhead.wait_moves() + + # Down + self.toolhead.manual_move([None, None, 2.0], 100.0) + + # Query + dist, _samples = self._sample(self.z_settling_time, 10) + dist = 2.0 - dist + + # Back + self.toolhead.manual_move(start_pos, 100.0) + self.toolhead.wait_moves() + + delta = epos[2] - dist + gcmd.respond_info("Comparing @ %.4f,%.4f" % (start_pos[0], start_pos[1])) + gcmd.respond_info("Contact: %.5f mm" % (epos[2],)) + gcmd.respond_info("Proximity: %.5f mm" % (dist,)) + gcmd.respond_info("Delta: %.3f um" % (delta * 1000,)) + self.last_probe_result = "ok" + self.last_offset_result = { + "position": (start_pos[0], start_pos[1], epos[2]), + "delta": delta, + } + + +def prb_proberesult(beacon, test_pos): + x, y, z = beacon.get_offsets() + return manual_probe.ProbeResult( + test_pos[0] + x, + test_pos[1] + y, + test_pos[2] - z, + test_pos[0], + test_pos[1], + test_pos[2], + ) + + +def prb_identity(beacon, test_pos): + return test_pos + + +class BeaconModel: + @classmethod + def load(cls, name, config, beacon): + coef = config.getfloatlist("model_coef") + temp = config.getfloat("model_temp") + domain = config.getfloatlist("model_domain", count=2) + [min_z, max_z] = config.getfloatlist("model_range", count=2) + offset = config.getfloat("model_offset", 0.0) + poly = Polynomial(coef, domain) + return BeaconModel(name, beacon, poly, temp, min_z, max_z, offset) + + def __init__(self, name, beacon, poly, temp, min_z, max_z, offset=0): + self.name = name + self.beacon = beacon + self.poly = poly + self.min_z = min_z + self.max_z = max_z + self.temp = temp + self.offset = offset + + def save(self, beacon, show_message=True): + configfile = beacon.printer.lookup_object("configfile") + sensor_name = "" if beacon.id.is_unnamed() else "sensor %s " % (beacon.id.name) + section = "beacon " + sensor_name + "model " + self.name + configfile.set(section, "model_coef", ",\n ".join(map(str, self.poly.coef))) + configfile.set(section, "model_domain", ",".join(map(str, self.poly.domain))) + configfile.set(section, "model_range", "%f,%f" % (self.min_z, self.max_z)) + configfile.set(section, "model_temp", "%f" % (self.temp)) + configfile.set(section, "model_offset", "%.5f" % (self.offset,)) + if show_message: + beacon.gcode.respond_info( + "Beacon calibration for model '%s' has " + "been updated\nfor the current session. The SAVE_CONFIG " + "command will\nupdate the printer config file and restart " + "the printer." % (self.name,) + ) + + def freq_to_dist_raw(self, freq): + [begin, end] = self.poly.domain + invfreq = 1 / freq + if invfreq > end: + return float("inf") + elif invfreq < begin: + return float("-inf") + else: + return float(self.poly(invfreq) - self.offset) + + def freq_to_dist(self, freq, temp): + if self.temp is not None and self.beacon.model_temp is not None: + freq = self.beacon.model_temp.compensate(freq, temp, self.temp) + return self.freq_to_dist_raw(freq) + + def dist_to_freq_raw(self, dist, max_e=0.00000001): + if dist < self.min_z or dist > self.max_z: + msg = ( + "Attempted to map out-of-range distance %f, valid range " + "[%.3f, %.3f]" % (dist, self.min_z, self.max_z) + ) + raise self.beacon.printer.command_error(msg) + dist += self.offset + [begin, end] = self.poly.domain + for _ in range(0, 50): + f = (end + begin) / 2 + v = self.poly(f) + if abs(v - dist) < max_e: + return float(1.0 / f) + elif v < dist: + begin = f + else: + end = f + raise self.beacon.printer.command_error("Beacon model convergence error") + + def dist_to_freq(self, dist, temp, max_e=0.00000001): + freq = self.dist_to_freq_raw(dist, max_e) + if self.temp is not None and self.beacon.model_temp is not None: + freq = self.beacon.model_temp.compensate(freq, self.temp, temp) + return freq + + +class BeaconMCUTempHelper: + def __init__(self, temp_room, temp_hot, ref_room, ref_hot, adc_room, adc_hot): + self.temp_room = temp_room + self.temp_hot = temp_hot + self.ref_room = ref_room + self.ref_hot = ref_hot + self.adc_room = adc_room + self.adc_hot = adc_hot + + def compensate(self, beacon, mcu_temp, supply): + temp_mcu_uncomp = self.temp_room + (self.temp_hot - self.temp_room) * ( + mcu_temp / beacon.temp_smooth_count - self.adc_room * self.ref_room + ) / (self.adc_hot * self.ref_hot - self.adc_room * self.ref_room) + ref_comp = self.ref_room + (self.ref_hot - self.ref_room) * ( + temp_mcu_uncomp - self.temp_room + ) / (self.temp_hot - self.temp_room) + temp_mcu_comp = self.temp_room + (self.temp_hot - self.temp_room) * ( + mcu_temp / beacon.temp_smooth_count * ref_comp + - self.adc_room * self.ref_room + ) / (self.adc_hot * self.ref_hot - self.adc_room * self.ref_room) + supply_voltage = ( + 4.0 * supply * ref_comp / beacon.temp_smooth_count * beacon.inv_adc_max + ) + return (temp_mcu_comp, supply_voltage) + + @classmethod + def build_with_nvm(cls, beacon): + nvm_data = beacon.beacon_nvm_read_cmd.send([8, 65534]) + if nvm_data["offset"] == 65534: + lower, upper = struct.unpack("> 8) & 0xF) + temp_hot = ((lower >> 12) & 0xFF) + 0.1 * ((lower >> 20) & 0xF) + adc_room = (upper >> 8) & 0xFFF + adc_hot = (upper >> 20) & 0xFFF + ref_room_raw, ref_hot_raw = struct.unpack(" 0: + vk = vk + self.beta / dt * rk + self.xl = xk + self.vl = vk + return xk + + def value(self): + return self.xl + + +class StreamingHelper: + def __init__(self, beacon, callback, completion_callback, latency): + self.beacon = beacon + self.cb = callback + self.completion_cb = completion_callback + self.completion = self.beacon.reactor.completion() + + self.latency_key = None + if latency is not None: + self.latency_key = self.beacon.request_stream_latency(latency) + + self.beacon._stream_callbacks[self] = self._handle + self.beacon._start_streaming() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + def _handle(self, sample): + try: + self.cb(sample) + except StopStreaming: + self.completion.complete(()) + + def stop(self): + if self not in self.beacon._stream_callbacks: + return + del self.beacon._stream_callbacks[self] + self.beacon._stop_streaming() + if self.latency_key is not None: + self.beacon.drop_stream_latency_request(self.latency_key) + if self.completion_cb is not None: + self.completion_cb() + + def wait(self): + self.completion.wait() + self.stop() + + +class StopStreaming(Exception): + pass + + +class BeaconProbeWrapper: + def __init__(self, beacon): + self.beacon = beacon + self.results = None + + def multi_probe_begin(self): + return self.beacon.multi_probe_begin() + + def multi_probe_end(self): + return self.beacon.multi_probe_end() + + def get_offsets(self, _gcmd=None): + return self.beacon.get_offsets() + + def get_lift_speed(self, gcmd=None): + return self.beacon.get_lift_speed(gcmd) + + def run_probe(self, gcmd, *args, **kwargs): + result = self.beacon.run_probe(gcmd) + result = self.beacon.compat_create_probe_result(result) + if self.results is not None: + self.results.append(result) + return result + + def get_probe_params(self, gcmd=None): + return {"lift_speed": self.beacon.get_lift_speed(gcmd)} + + def start_probe_session(self, gcmd): + self.multi_probe_begin() + self.results = [] + return self + + def end_probe_session(self): + self.results = None + self.multi_probe_end() + + def pull_probed_results(self): + results = self.results + if results is None: + return [] + else: + self.results = [] + return results + + def get_status(self, eventtime): + return {"name": "beacon"} + + +class BeaconTempWrapper: + def __init__(self, beacon): + self.beacon = beacon + + def get_temp(self, eventtime): + return self.beacon.last_temp, 0 + + def get_status(self, eventtime): + return { + "temperature": round(self.beacon.last_temp, 2), + "measured_min_temp": round(self.beacon.measured_min, 2), + "measured_max_temp": round(self.beacon.measured_max, 2), + } + + +class BeaconEndstopShared: + def __init__(self, beacon): + self.beacon = beacon + + ffi_main, ffi_lib = chelper.get_ffi() + self._trdispatch = ffi_main.gc(ffi_lib.trdispatch_alloc(), ffi_lib.free) + self._trsync = MCU_trsync(self.beacon._mcu, self._trdispatch) + self._trsyncs = [self._trsync] + + beacon.printer.register_event_handler( + "klippy:mcu_identify", self._handle_mcu_identify + ) + + def _handle_mcu_identify(self): + self.toolhead = self.beacon.printer.lookup_object("toolhead") + kin = self.toolhead.get_kinematics() + for stepper in kin.get_steppers(): + if stepper.is_active_axis("z"): + self.add_stepper(stepper) + + def add_stepper(self, stepper): + trsyncs = {trsync.get_mcu(): trsync for trsync in self._trsyncs} + stepper_mcu = stepper.get_mcu() + trsync = trsyncs.get(stepper_mcu) + if trsync is None: + trsync = MCU_trsync(stepper_mcu, self._trdispatch) + self._trsyncs.append(trsync) + trsync.add_stepper(stepper) + # Check for unsupported multi-mcu shared stepper rails, duplicated + # from MCU_endstop + sname = stepper.get_name() + if sname.startswith("stepper_"): + for ot in self._trsyncs: + for s in ot.get_steppers(): + if ot is not trsync and s.get_name().startswith(sname[:9]): + raise self.beacon.printer.config_error( + "Multi-mcu homing not supported on multi-mcu shared axis" + ) + + def get_steppers(self): + return [s for trsync in self._trsyncs for s in trsync.get_steppers()] + + def trsync_start(self, print_time): + self._trigger_completion = self.beacon.reactor.completion() + expire_timeout = self.beacon.trsync_timeout + for i, trsync in enumerate(self._trsyncs): + try: + trsync.start(print_time, self._trigger_completion, expire_timeout) + except TypeError: + offset = float(i) / len(self._trsyncs) + trsync.start( + print_time, offset, self._trigger_completion, expire_timeout + ) + ffi_main, ffi_lib = chelper.get_ffi() + ffi_lib.trdispatch_start(self._trdispatch, self._trsync.REASON_HOST_REQUEST) + + def trsync_stop(self, home_end_time): + self._trsync.set_home_end_time(home_end_time) + if self.beacon._mcu.is_fileoutput(): + self._trigger_completion.complete(True) + self._trigger_completion.wait() + ffi_main, ffi_lib = chelper.get_ffi() + ffi_lib.trdispatch_stop(self._trdispatch) + res = [trsync.stop() for trsync in self._trsyncs] + if any([r == self._trsync.REASON_COMMS_TIMEOUT for r in res]): + cmderr = self.beacon.printer.command_error + raise cmderr("Communication timeout during homing") + if res[0] != self._trsync.REASON_ENDSTOP_HIT: + return 0.0 + return None + + +class BeaconEndstopWrapper: + def __init__(self, beacon): + self.beacon = beacon + self._shared = beacon._endstop_shared + + printer = beacon.printer + printer.register_event_handler( + "homing:home_rails_begin", self._handle_home_rails_begin + ) + printer.register_event_handler( + "homing:home_rails_end", self._handle_home_rails_end + ) + + self.is_homing = False + + def _handle_home_rails_begin(self, homing_state, rails): + self.is_homing = False + + def _handle_home_rails_end(self, homing_state, rails): + if self.beacon.model is None: + return + + if not self.is_homing: + return + + if 2 not in homing_state.get_axes(): + return + + # After homing Z we perform a measurement and adjust the toolhead + # kinematic position. + dist, samples = self.beacon._sample(self.beacon.z_settling_time, 10) + if math.isinf(dist): + logging.error("Post-homing adjustment measured samples %s", samples) + raise self.beacon.printer.command_error( + "Toolhead stopped below model range" + ) + homing_state.set_homed_position([None, None, dist]) + + def get_mcu(self): + return self.beacon._mcu + + def add_stepper(self, stepper): + self._shared.add_stepper(stepper) + + def get_steppers(self): + return self._shared.get_steppers() + + def home_start( + self, print_time, sample_time, sample_count, rest_time, triggered=True + ): + if self.beacon.model is None: + raise self.beacon.printer.command_error("No Beacon model loaded") + + self.is_homing = True + self.beacon._apply_threshold() + self.beacon._sample_async() + + self._shared.trsync_start(print_time) + + etrsync = self._shared._trsync + self.beacon.beacon_home_cmd.send( + [ + etrsync.get_oid(), + etrsync.REASON_ENDSTOP_HIT, + 0, + ] + ) + return self._shared._trigger_completion + + def home_wait(self, home_end_time): + ret = self._shared.trsync_stop(home_end_time) + self.beacon.beacon_stop_home_cmd.send() + if ret is not None: + return ret + return home_end_time + + def query_endstop(self, print_time): + if self.beacon.model is None: + return 1 + self.beacon._mcu.print_time_to_clock(print_time) + sample = self.beacon._sample_async() + if self.beacon.trigger_freq <= sample["freq"]: + return 1 + else: + return 0 + + def get_position_endstop(self): + return self.beacon.trigger_distance + + +class BeaconContactEndstopWrapper: + def __init__(self, beacon, config): + self.beacon = beacon + self._shared = beacon._endstop_shared + + gcode_macro = beacon.printer.load_object(config, "gcode_macro") + self.activate_gcode = gcode_macro.load_template( + config, "contact_activate_gcode", "" + ) + self.deactivate_gcode = gcode_macro.load_template( + config, "contact_deactivate_gcode", "" + ) + self.max_hotend_temp = config.getfloat("contact_max_hotend_temperature", 180.0) + + def get_mcu(self): + return self.beacon._mcu + + def add_stepper(self, stepper): + self._shared.add_stepper(stepper) + + def get_steppers(self): + return self._shared.get_steppers() + + def home_start( + self, print_time, sample_time, sample_count, rest_time, triggered=True + ): + extruder = self.beacon.toolhead.get_extruder() + if extruder is not None: + curtime = self.beacon.reactor.monotonic() + cur_temp = extruder.get_heater().get_status(curtime)["temperature"] + if cur_temp >= self.max_hotend_temp: + raise self.beacon.printer.command_error( + "Current hotend temperature %.1f exceeds maximum allowed temperature %.1f" + % (cur_temp, self.max_hotend_temp) + ) + + self.is_homing = True + self.beacon._sample_async() + self._shared.trsync_start(print_time) + etrsync = self._shared._trsync + if self.beacon.beacon_contact_set_latency_min_cmd is not None: + self.beacon.beacon_contact_set_latency_min_cmd.send( + [self.beacon.contact_latency_min] + ) + if self.beacon.beacon_contact_set_sensitivity_cmd is not None: + self.beacon.beacon_contact_set_sensitivity_cmd.send( + [self.beacon.contact_sensitivity] + ) + self.beacon.beacon_contact_home_cmd.send( + [ + etrsync.get_oid(), + etrsync.REASON_ENDSTOP_HIT, + 0, + 0, + ] + ) + return self._shared._trigger_completion + + def home_wait(self, home_end_time): + try: + ret = self._shared.trsync_stop(home_end_time) + if ret is not None: + return ret + if self.beacon._mcu.is_fileoutput(): + return home_end_time + self.beacon.toolhead.wait_moves() + deadline = self.beacon.reactor.monotonic() + 0.5 + while True: + ret = self.beacon.beacon_contact_query_cmd.send([]) + if ret["triggered"] == 0: + now = self.beacon.reactor.monotonic() + if now >= deadline: + raise self.beacon.printer.command_error( + "Timeout getting contact time" + ) + self.beacon.reactor.pause(now + 0.001) + continue + time = self.beacon._clock32_to_time(ret["detect_clock"]) + ffi_main, ffi_lib = chelper.get_ffi() + data = ffi_main.new("struct pull_move[1]") + count = ffi_lib.trapq_extract_old(self.beacon.trapq, data, 1, 0.0, time) + if time >= home_end_time: + return 0.0 + if count: + accel = data[0].accel + if accel < 0: + logging.info("Contact triggered while decelerating") + raise self.beacon.printer.command_error( + "No trigger on probe after full movement" + ) + elif accel > 0: + raise self.beacon.printer.command_error( + "Contact triggered while accelerating" + ) + return time + finally: + self.beacon.beacon_contact_stop_home_cmd.send() + + def query_endstop(self, print_time): + return 0 + + def get_position_endstop(self): + return 0 + + +HOMING_AUTOCAL_CALIBRATE_ALWAYS = 0 +HOMING_AUTOCAL_CALIBRATE_UNHOMED = 1 +HOMING_AUTOCAL_CALIBRATE_NEVER = 2 +HOMING_AUTOCAL_CALIBRATE_CHOICES = { + "always": HOMING_AUTOCAL_CALIBRATE_ALWAYS, + "unhomed": HOMING_AUTOCAL_CALIBRATE_UNHOMED, + "never": HOMING_AUTOCAL_CALIBRATE_NEVER, +} +HOMING_AUTOCAL_METHOD_CONTACT = 0 +HOMING_AUTOCAL_METHOD_PROXIMITY = 1 +HOMING_AUTOCAL_METHOD_PROXIMITY_IF_AVAILABLE = 2 +HOMING_AUTOCAL_METHOD_CHOICES = { + "contact": HOMING_AUTOCAL_METHOD_CONTACT, + "proximity": HOMING_AUTOCAL_METHOD_PROXIMITY, + "proximity_if_available": HOMING_AUTOCAL_METHOD_PROXIMITY_IF_AVAILABLE, +} +HOMING_AUTOCAL_CHOICES_METHOD = {v: k for k, v in HOMING_AUTOCAL_METHOD_CHOICES.items()} + + +class BeaconHomingHelper: + @classmethod + def create(cls, beacon, config): + home_xy_position = config.getfloatlist("home_xy_position", None, count=2) + if home_xy_position is None: + return None + return BeaconHomingHelper(beacon, config, home_xy_position) + + def __init__(self, beacon, config, home_xy_position): + self.beacon = beacon + self.home_pos = home_xy_position + + for section in ["safe_z_home", "homing_override"]: + if config.has_section(section): + raise config.error( + "home_xy_position cannot be used with [%s]" % (section,) + ) + + self.z_hop = config.getfloat("home_z_hop", 0.0) + self.z_hop_speed = config.getfloat("home_z_hop_speed", 15.0, above=0.0) + self.xy_move_speed = config.getfloat("home_xy_move_speed", 50.0, above=0.0) + self.home_y_before_x = config.getboolean("home_y_before_x", False) + self.method = config.getchoice( + "home_method", HOMING_AUTOCAL_METHOD_CHOICES, "proximity" + ) + self.method_when_homed = config.getchoice( + "home_method_when_homed", + HOMING_AUTOCAL_METHOD_CHOICES, + HOMING_AUTOCAL_CHOICES_METHOD[self.method], + ) + self.autocal_create_model = config.getchoice( + "home_autocalibrate", HOMING_AUTOCAL_CALIBRATE_CHOICES, "always" + ) + + gcode_macro = beacon.printer.load_object(config, "gcode_macro") + self.tmpl_pre_xy = gcode_macro.load_template(config, "home_gcode_pre_xy", "") + self.tmpl_post_xy = gcode_macro.load_template(config, "home_gcode_post_xy", "") + self.tmpl_pre_x = gcode_macro.load_template(config, "home_gcode_pre_x", "") + self.tmpl_post_x = gcode_macro.load_template(config, "home_gcode_post_x", "") + self.tmpl_pre_y = gcode_macro.load_template(config, "home_gcode_pre_y", "") + self.tmpl_post_y = gcode_macro.load_template(config, "home_gcode_post_y", "") + self.tmpl_pre_z = gcode_macro.load_template(config, "home_gcode_pre_z", "") + self.tmpl_post_z = gcode_macro.load_template(config, "home_gcode_post_z", "") + + # Ensure homing is loaded so we can override G28 + beacon.printer.load_object(config, "homing") + self.gcode = gcode = beacon.gcode + self.prev_gcmd = gcode.register_command("G28", None) + gcode.register_command("G28", self.cmd_G28) + + def _maybe_zhop(self, toolhead): + if self.z_hop != 0: + curtime = self.beacon.reactor.monotonic() + kin = toolhead.get_kinematics() + kin_status = kin.get_status(curtime) + pos = toolhead.get_position() + + move = [None, None, self.z_hop] + if "z" not in kin_status["homed_axes"]: + pos[2] = 0 + self.beacon.compat_toolhead_set_position_homing_z(toolhead, pos) + toolhead.manual_move(move, self.z_hop_speed) + toolhead.wait_moves() + self.beacon.compat_kin_note_z_not_homed(kin) + elif pos[2] < self.z_hop: + toolhead.manual_move(move, self.z_hop_speed) + toolhead.wait_moves() + + def _run_hook(self, template, params, raw_params): + ctx = template.create_template_context() + ctx["params"] = params + ctx["rawparams"] = raw_params + template.run_gcode_from_command(ctx) + + def cmd_G28(self, gcmd): + toolhead = self.beacon.printer.lookup_object("toolhead") + orig_params = gcmd.get_command_parameters() + raw_params = gcmd.get_raw_command_parameters() + + self._maybe_zhop(toolhead) + + want_x, want_y, want_z = [gcmd.get(a, None) is not None for a in "XYZ"] + # No axes given => home them all + if not (want_x or want_y or want_z): + want_x = want_y = want_z = True + + if want_x or want_y: + self._run_hook(self.tmpl_pre_xy, orig_params, raw_params) + if self.home_y_before_x: + axis_order = "yx" + else: + axis_order = "xy" + for axis in axis_order: + if axis == "x" and want_x: + self._run_hook(self.tmpl_pre_x, orig_params, raw_params) + cmd = self.gcode.create_gcode_command("G28", "G28", {"X": "0"}) + self.prev_gcmd(cmd) + self._run_hook(self.tmpl_post_x, orig_params, raw_params) + elif axis == "y" and want_y: + self._run_hook(self.tmpl_pre_y, orig_params, raw_params) + cmd = self.gcode.create_gcode_command("G28", "G28", {"Y": "0"}) + self.prev_gcmd(cmd) + self._run_hook(self.tmpl_post_y, orig_params, raw_params) + self._run_hook(self.tmpl_post_xy, orig_params, raw_params) + + if want_z: + self._run_hook(self.tmpl_pre_z, orig_params, raw_params) + curtime = self.beacon.reactor.monotonic() + kin = toolhead.get_kinematics() + kin_status = kin.get_status(curtime) + if "xy" not in kin_status["homed_axes"]: + raise gcmd.error("Must home X and Y axes before homing Z") + + method = self.method + if "z" in kin_status["homed_axes"]: + method = self.method_when_homed + + # G28 is not normally an extended gcode, so we need this hack + args = gcmd.get_commandline().split(" ") + for arg in args: + kv = arg.split("=") + if len(kv) == 2 and kv[0].strip().lower() == "method": + method = HOMING_AUTOCAL_METHOD_CHOICES.get( + kv[1].strip().lower(), None + ) + if method is None: + raise gcmd.error( + "Invalid homing method, valid choices: proximity, proximity_if_available, contact" + ) + break + + pos = [self.home_pos[0], self.home_pos[1]] + + if method == HOMING_AUTOCAL_METHOD_PROXIMITY_IF_AVAILABLE: + if self.beacon.model is not None: + method = HOMING_AUTOCAL_METHOD_PROXIMITY + else: + method = HOMING_AUTOCAL_METHOD_CONTACT + + if method == HOMING_AUTOCAL_METHOD_CONTACT: + toolhead.manual_move(pos, self.xy_move_speed) + + calibrate = True + if self.autocal_create_model == HOMING_AUTOCAL_CALIBRATE_UNHOMED: + calibrate = "z" not in kin_status["homed_axes"] + elif self.autocal_create_model == HOMING_AUTOCAL_CALIBRATE_NEVER: + calibrate = False + + override = gcmd.get("CALIBRATE", None) + if override is not None: + if override.lower() in ["=0", "=no", "=false"]: + calibrate = False + else: + calibrate = True + + cmd = "BEACON_AUTO_CALIBRATE" + params = {} + if not calibrate: + params["SKIP_MODEL_CREATION"] = "1" + cmd = self.gcode.create_gcode_command(cmd, cmd, params) + self.beacon.cmd_BEACON_AUTO_CALIBRATE(cmd) + elif method == HOMING_AUTOCAL_METHOD_PROXIMITY: + pos[0] -= self.beacon.x_offset + pos[1] -= self.beacon.y_offset + toolhead.manual_move(pos, self.xy_move_speed) + cmd = self.gcode.create_gcode_command("G28", "G28", {"Z": "0"}) + self.prev_gcmd(cmd) + else: + raise gcmd.error("Invalid homing method '%s'" % (method,)) + self._maybe_zhop(toolhead) + self._run_hook(self.tmpl_post_z, orig_params, raw_params) + + +class BeaconHomingState: + def get_axes(self): + return [2] + + def get_trigger_position(self, stepper_name): + raise Exception("get_trigger_position not supported") + + def set_stepper_adjustment(self, stepper_name, adjustment): + pass + + def set_homed_position(self, pos): + pass + + +class BeaconMeshHelper: + @classmethod + def create(cls, beacon, config): + if config.has_section("bed_mesh"): + mesh_config = config.getsection("bed_mesh") + if mesh_config.get("mesh_radius", None) is not None: + return None # Use normal bed meshing for round beds + return BeaconMeshHelper(beacon, config, mesh_config) + else: + return None + + def __init__(self, beacon, config, mesh_config): + self.beacon = beacon + self.scipy = None + self.mesh_config = mesh_config + self.bm = self.beacon.printer.load_object(mesh_config, "bed_mesh") + + self.speed = mesh_config.getfloat("speed", 50.0, above=0.0, note_valid=False) + self.def_min_x, self.def_min_y = mesh_config.getfloatlist( + "mesh_min", count=2, note_valid=False + ) + self.def_max_x, self.def_max_y = mesh_config.getfloatlist( + "mesh_max", count=2, note_valid=False + ) + + if self.def_min_x > self.def_max_x: + self.def_min_x, self.def_max_x = self.def_max_x, self.def_min_x + if self.def_min_y > self.def_max_y: + self.def_min_y, self.def_max_y = self.def_max_y, self.def_min_y + + self.def_res_x, self.def_res_y = mesh_config.getintlist( + "probe_count", count=2, note_valid=False + ) + self.rri = mesh_config.getint( + "relative_reference_index", None, note_valid=False + ) + self.zero_ref_pos = mesh_config.getfloatlist( + "zero_reference_position", None, count=2 + ) + self.zero_ref_pos_cluster_size = config.getfloat( + "zero_reference_cluster_size", 1, minval=0 + ) + self.dir = config.getchoice( + "mesh_main_direction", {"x": "x", "X": "x", "y": "y", "Y": "y"}, "y" + ) + self.overscan = config.getfloat("mesh_overscan", -1, minval=0) + self.cluster_size = config.getfloat("mesh_cluster_size", 1, minval=0) + self.runs = config.getint("mesh_runs", 1, minval=1) + self.adaptive_margin = mesh_config.getfloat( + "adaptive_margin", 0, note_valid=False + ) + + contact_def_min = config.getfloatlist( + "contact_mesh_min", + default=None, + count=2, + ) + contact_def_max = config.getfloatlist( + "contact_mesh_max", + default=None, + count=2, + ) + + xo = self.beacon.x_offset + yo = self.beacon.y_offset + + def_contact_min = contact_def_min + if contact_def_min is None: + def_contact_min = ( + max(self.def_min_x - xo, self.def_min_x), + max(self.def_min_y - yo, self.def_min_y), + ) + + def_contact_max = contact_def_max + if contact_def_max is None: + def_contact_max = ( + min(self.def_max_x - xo, self.def_max_x), + min(self.def_max_y - yo, self.def_max_y), + ) + + min_x = def_contact_min[0] + max_x = def_contact_max[0] + min_y = def_contact_min[1] + max_y = def_contact_max[1] + self.def_contact_min = (min(min_x, max_x), min(min_y, max_y)) + self.def_contact_max = (max(min_x, max_x), max(min_y, max_y)) + + if self.zero_ref_pos is not None and self.rri is not None: + logging.info( + "beacon: both 'zero_reference_position' and " + "'relative_reference_index' options are specified. The" + " former will be used" + ) + + self.faulty_regions = [] + for i in list(range(1, 100, 1)): + start = mesh_config.getfloatlist( + "faulty_region_%d_min" % (i,), None, count=2 + ) + if start is None: + break + end = mesh_config.getfloatlist("faulty_region_%d_max" % (i,), count=2) + x_min = min(start[0], end[0]) + x_max = max(start[0], end[0]) + y_min = min(start[1], end[1]) + y_max = max(start[1], end[1]) + self.faulty_regions.append(Region(x_min, x_max, y_min, y_max)) + + self.exclude_object = None + beacon.printer.register_event_handler("klippy:connect", self._handle_connect) + + self.gcode = beacon.gcode + self.prev_gcmd = self.gcode.register_command("BED_MESH_CALIBRATE", None) + self.gcode.register_command( + "BED_MESH_CALIBRATE", + self.cmd_BED_MESH_CALIBRATE, + desc=self.cmd_BED_MESH_CALIBRATE_help, + ) + + cmd_BED_MESH_CALIBRATE_help = "Perform Mesh Bed Leveling" + + def cmd_BED_MESH_CALIBRATE(self, gcmd): + method = gcmd.get("METHOD", "beacon").lower() + probe_method = gcmd.get( + "PROBE_METHOD", self.beacon.default_probe_method + ).lower() + if probe_method != "proximity": + method = "automatic" + if method == "beacon": + self.calibrate(gcmd) + else: + # For backwards compatibility, ZRP is specified in probe coordinates. + # When in contact mode, we need to remove the offset first + if hasattr(self.bm.bmc, "zero_ref_pos"): + zrp = self.zero_ref_pos + if zrp is not None and probe_method == "contact": + zrp = (zrp[0] + self.beacon.x_offset, zrp[1] + self.beacon.y_offset) + self.bm.bmc.zero_ref_pos = zrp + # In contact mode, clamp MESH_MIN and MESH_MAX in case they aren't given, to + # ensure the requested area is safe to probe. This results in a slightly smaller + # mesh but guarantees it can be processed. + if probe_method == "contact": + params = gcmd.get_command_parameters() + extra_params = {} + if "MESH_MIN" not in params: + extra_params["MESH_MIN"] = ",".join(map(str, self.def_contact_min)) + if "MESH_MAX" not in params: + extra_params["MESH_MAX"] = ",".join(map(str, self.def_contact_max)) + if extra_params: + extra_params.update(params) + gcmd = self.gcode.create_gcode_command( + gcmd.get_command(), + gcmd.get_commandline() + + "".join([" " + k + "=" + v for k, v in extra_params.items()]), + extra_params, + ) + self.beacon._current_probe = probe_method + self.prev_gcmd(gcmd) + + def _handle_connect(self): + self.exclude_object = self.beacon.printer.lookup_object("exclude_object", None) + + if self.overscan < 0: + # Auto determine a safe overscan amount + toolhead = self.beacon.printer.lookup_object("toolhead") + curtime = self.beacon.reactor.monotonic() + status = toolhead.get_kinematics().get_status(curtime) + xo = self.beacon.x_offset + yo = self.beacon.y_offset + settings = { + "x": { + "range": [self.def_min_x - xo, self.def_max_x - xo], + "machine": [status["axis_minimum"][0], status["axis_maximum"][0]], + "count": self.def_res_y, + }, + "y": { + "range": [self.def_min_y - yo, self.def_max_y - yo], + "machine": [status["axis_minimum"][1], status["axis_maximum"][1]], + "count": self.def_res_x, + }, + }[self.dir] + + r = settings["range"] + m = settings["machine"] + space = (r[1] - r[0]) / (float(settings["count"] - 1)) + self.overscan = min( + [ + max(0, r[0] - m[0]), + max(0, m[1] - r[1]), + space + 2.0, # A half circle with 2mm lead in/out + ] + ) + + def _generate_path(self): + xo = self.beacon.x_offset + yo = self.beacon.y_offset + settings = { + "x": { + "range_aligned": [self.min_x - xo, self.max_x - xo], + "range_perpendicular": [self.min_y - yo, self.max_y - yo], + "count": self.res_y, + "swap_coord": False, + }, + "y": { + "range_aligned": [self.min_y - yo, self.max_y - yo], + "range_perpendicular": [self.min_x - xo, self.max_x - xo], + "count": self.res_x, + "swap_coord": True, + }, + }[self.dir] + + # We build the path in "normalized" coordinates and then simply + # swap x and y at the end if we need to + begin_a, end_a = settings["range_aligned"] + begin_p, end_p = settings["range_perpendicular"] + swap_coord = settings["swap_coord"] + step = (end_p - begin_p) / (float(settings["count"] - 1)) + points = [] + corner_radius = min(step / 2, self.overscan) + for i in range(0, settings["count"]): + pos_p = begin_p + step * i + even = i % 2 == 0 # If even we are going 'right', else 'left' + pa = (begin_a, pos_p) if even else (end_a, pos_p) + pb = (end_a, pos_p) if even else (begin_a, pos_p) + + line = (pa, pb) + + if len(points) > 0 and corner_radius > 0: + # We need to insert an overscan corner. Basically we insert + # a rounded rectangle to smooth out the transition and retain + # as much speed as we can. + # + # ---|---< + # / + # | + # \ + # ---|---> + # + # We just need to draw the two 90 degree arcs. They contain + # the endpoints of the lines connecting everything. + if even: + center = begin_a - self.overscan + corner_radius + points += arc_points( + center, pos_p - step + corner_radius, corner_radius, -90, -90 + ) + points += arc_points( + center, pos_p - corner_radius, corner_radius, -180, -90 + ) + else: + center = end_a + self.overscan - corner_radius + points += arc_points( + center, pos_p - step + corner_radius, corner_radius, -90, 90 + ) + points += arc_points( + center, pos_p - corner_radius, corner_radius, 0, 90 + ) + + points.append(line[0]) + points.append(line[1]) + + if swap_coord: + for i in range(len(points)): + x, y = points[i] + points[i] = (y, x) + + return points + + def calibrate(self, gcmd): + use_full = gcmd.get_int("USE_CONTACT_AREA", 0) == 0 + self.min_x, self.min_y = coord_fallback( + gcmd, + "MESH_MIN", + float_parse, + self.def_min_x if use_full else self.def_contact_min[0], + self.def_min_y if use_full else self.def_contact_min[1], + lambda v, d: max(v, d), + ) + self.max_x, self.max_y = coord_fallback( + gcmd, + "MESH_MAX", + float_parse, + self.def_max_x if use_full else self.def_contact_max[0], + self.def_max_y if use_full else self.def_contact_max[1], + lambda v, d: min(v, d), + ) + self.res_x, self.res_y = coord_fallback( + gcmd, + "PROBE_COUNT", + int, + self.def_res_x, + self.def_res_y, + lambda v, _d: max(v, 3), + ) + self.profile_name = gcmd.get("PROFILE", "default") + + if self.min_x > self.max_x: + self.min_x, self.max_x = ( + max(self.max_x, self.def_min_x), + min(self.min_x, self.def_max_x), + ) + if self.min_y > self.max_y: + self.min_y, self.max_y = ( + max(self.max_y, self.def_min_y), + min(self.min_y, self.def_max_y), + ) + + # If the user gave RRI _on gcode_ then use it, else use zero_ref_pos + # if we have it, and finally use config RRI if we have it. + rri = gcmd.get_int("RELATIVE_REFERENCE_INDEX", None) + if rri is not None: + self.zero_ref_mode = ("rri", rri) + elif self.zero_ref_pos is not None: + self.zero_ref_mode = ("pos", self.zero_ref_pos) + self.zero_ref_val = None + self.zero_ref_bin = [] + elif self.rri is not None: + self.zero_ref_mode = ("rri", self.rri) + else: + self.zero_ref_mode = None + + # If the user requested adaptive meshing, try to shrink the values we just configured + if gcmd.get_int("ADAPTIVE", 0): + if self.exclude_object is not None: + margin = gcmd.get_float("ADAPTIVE_MARGIN", self.adaptive_margin) + self._shrink_to_excluded_objects(gcmd, margin) + else: + gcmd.respond_info( + "Requested adaptive mesh, but [exclude_object] is not enabled. Ignoring." + ) + + self.step_x = (self.max_x - self.min_x) / (self.res_x - 1) + self.step_y = (self.max_y - self.min_y) / (self.res_y - 1) + + self.toolhead = self.beacon.toolhead + path = self._generate_path() + + probe_speed = gcmd.get_float("PROBE_SPEED", self.beacon.speed, above=0.0) + self.beacon._move_to_probing_height(probe_speed) + + speed = gcmd.get_float("SPEED", self.speed, above=0.0) + runs = gcmd.get_int("RUNS", self.runs, minval=1) + + try: + self.beacon._start_streaming() + + # Move to first location + x, y = path[0] + self.toolhead.manual_move([x, y, None], speed) + self.toolhead.wait_moves() + + self.beacon._sample_printtime_sync(5) + clusters = self._sample_mesh(gcmd, path, speed, runs) + + if self.zero_ref_mode and self.zero_ref_mode[0] == "pos": + # If we didn't collect anything, hop over to the zero point + # and sample. Otherwise, grab the median of what we collected. + if len(self.zero_ref_bin) == 0: + self._collect_zero_ref(speed, self.zero_ref_mode[1]) + else: + self.zero_ref_val = median(self.zero_ref_bin) + + finally: + self.beacon._stop_streaming() + + matrix = self._process_clusters(clusters, gcmd) + self._apply_mesh(matrix, gcmd) + + def _shrink_to_excluded_objects(self, gcmd, margin): + bound_min_x, bound_max_x = None, None + bound_min_y, bound_max_y = None, None + objects = self.exclude_object.get_status().get("objects", {}) + if len(objects) == 0: + return + + for obj in objects: + for point in obj["polygon"]: + bound_min_x = opt_min(bound_min_x, point[0]) + bound_max_x = opt_max(bound_max_x, point[0]) + bound_min_y = opt_min(bound_min_y, point[1]) + bound_max_y = opt_max(bound_max_y, point[1]) + bound_min_x -= margin + bound_max_x += margin + bound_min_y -= margin + bound_max_y += margin + + # Calculate original step size and apply the new bounds + orig_span_x = self.max_x - self.min_x + orig_span_y = self.max_y - self.min_y + + if bound_min_x >= self.min_x: + self.min_x = bound_min_x + if bound_max_x <= self.max_x: + self.max_x = bound_max_x + if bound_min_y >= self.min_y: + self.min_y = bound_min_y + if bound_max_y <= self.max_y: + self.max_y = bound_max_y + + # Update resolution to retain approximately the same step size as before + self.res_x = int( + math.ceil(self.res_x * (self.max_x - self.min_x) / orig_span_x) + ) + self.res_y = int( + math.ceil(self.res_y * (self.max_y - self.min_y) / orig_span_y) + ) + # Guard against bicubic interpolation with 3 points on one axis + min_res = 3 + if max(self.res_x, self.res_y) > 6 and min(self.res_x, self.res_y) < 4: + min_res = 4 + self.res_x = max(self.res_x, min_res) + self.res_y = max(self.res_y, min_res) + + self.profile_name = None + + def _fly_path(self, path, speed, runs): + # Run through the path + for i in range(runs): + p = path if i % 2 == 0 else reversed(path) + for x, y in p: + self.toolhead.manual_move([x, y, None], speed) + self.toolhead.dwell(0.251) + self.toolhead.wait_moves() + + def _collect_zero_ref(self, speed, coord): + xo, yo = self.beacon.x_offset, self.beacon.y_offset + x, y = coord + self.toolhead.manual_move([x - xo, y - yo, None], speed) + dist, _samples = self.beacon._sample(50, 10) + self.zero_ref_val = dist + + def _is_valid_position(self, x, y): + return self.min_x <= x <= self.max_x and self.min_y <= y <= self.min_y + + def _is_faulty_coordinate(self, x, y, add_offsets=False): + if add_offsets: + xo, yo = self.beacon.x_offset, self.beacon.y_offset + x += xo + y += yo + for r in self.faulty_regions: + if r.is_point_within(x, y): + return True + return False + + def _sample_mesh(self, gcmd, path, speed, runs): + cs = gcmd.get_float("CLUSTER_SIZE", self.cluster_size, minval=0.0) + zcs = self.zero_ref_pos_cluster_size + if not (self.zero_ref_mode and self.zero_ref_mode[0] == "pos"): + zcs = 0 + + min_x, min_y = self.min_x, self.min_y + xo, yo = self.beacon.x_offset, self.beacon.y_offset + + clusters = {} + total_samples = [0] + invalid_samples = [0] + + def cb(sample): + total_samples[0] += 1 + d = sample["dist"] + x, y, z = sample["pos"][:3] + x += xo + y += yo + + if d is None or math.isinf(d): + if self._is_valid_position(x, y): + invalid_samples[0] += 1 + return + + # Calculate coordinate of the cluster we are in + xi = int(round((x - min_x) / self.step_x)) + yi = int(round((y - min_y) / self.step_y)) + if xi < 0 or self.res_x <= xi or yi < 0 or self.res_y <= yi: + return + + # If there's a cluster size limit, apply it here + if cs > 0: + xf = xi * self.step_x + min_x + yf = yi * self.step_y + min_y + dx = x - xf + dy = y - yf + dist = math.sqrt(dx * dx + dy * dy) + if dist > cs: + return + + # If we are looking for a zero reference, check if we + # are close enough and if so, add to the bin. + if zcs > 0: + dx = x - self.zero_ref_mode[1][0] + dy = y - self.zero_ref_mode[1][1] + dist = math.sqrt(dx * dx + dy * dy) + if dist <= zcs: + self.zero_ref_bin.append(d) + + k = (xi, yi) + + if k not in clusters: + clusters[k] = [] + clusters[k].append(d) + + with self.beacon.streaming_session(cb): + self._fly_path(path, speed, runs) + + gcmd.respond_info( + "Sampled %d total points over %d runs" % (total_samples[0], runs) + ) + if invalid_samples[0]: + gcmd.respond_info( + "!! Encountered %d invalid samples!" % (invalid_samples[0],) + ) + gcmd.respond_info("Samples binned in %d clusters" % (len(clusters),)) + + return clusters + + def _process_clusters(self, raw_clusters, gcmd): + parent_conn, child_conn = multiprocessing.Pipe() + dump_file = gcmd.get("FILENAME", None) + + def do(): + try: + child_conn.send( + (False, self._do_process_clusters(raw_clusters, dump_file)) + ) + except Exception: + child_conn.send((True, traceback.format_exc())) + child_conn.close() + + child = multiprocessing.Process(target=do) + child.daemon = True + child.start() + reactor = self.beacon.reactor + eventtime = reactor.monotonic() + while child.is_alive(): + eventtime = reactor.pause(eventtime + 0.1) + is_err, result = parent_conn.recv() + child.join() + parent_conn.close() + if is_err: + raise Exception("Error processing mesh: %s" % (result,)) + else: + is_inner_err, inner_result = result + if is_inner_err: + raise gcmd.error(inner_result) + else: + return inner_result + + def _do_process_clusters(self, raw_clusters, dump_file): + if dump_file: + with open(dump_file, "w") as f: + f.write("x,y,xp,xy,dist\n") + for yi in range(self.res_y): + for xi in range(self.res_x): + cluster = raw_clusters.get((xi, yi), []) + xp = xi * self.step_x + self.min_x + yp = yi * self.step_y + self.min_y + for dist in cluster: + f.write("%d,%d,%f,%f,%f\n" % (xi, yi, xp, yp, dist)) + + mask = self._generate_fault_mask() + matrix, faulty_regions = self._generate_matrix(raw_clusters, mask) + if len(faulty_regions) > 0: + error, interpolator_or_msg = self._load_interpolator() + if error: + return (True, interpolator_or_msg) + matrix = self._interpolate_faulty( + matrix, faulty_regions, interpolator_or_msg + ) + err = self._check_matrix(matrix) + if err is not None: + return (True, err) + return (False, self._finalize_matrix(matrix)) + + def _generate_fault_mask(self): + if len(self.faulty_regions) == 0: + return None + mask = np.full((self.res_y, self.res_x), True) + for r in self.faulty_regions: + r_xmin = max(0, int(math.ceil((r.x_min - self.min_x) / self.step_x))) + r_ymin = max(0, int(math.ceil((r.y_min - self.min_y) / self.step_y))) + r_xmax = min( + self.res_x - 1, int(math.floor((r.x_max - self.min_x) / self.step_x)) + ) + r_ymax = min( + self.res_y - 1, int(math.floor((r.y_max - self.min_y) / self.step_y)) + ) + for y in range(r_ymin, r_ymax + 1): + for x in range(r_xmin, r_xmax + 1): + mask[(y, x)] = False + return mask + + def _generate_matrix(self, raw_clusters, mask): + faulty_indexes = [] + matrix = np.empty((self.res_y, self.res_x)) + for (x, y), values in raw_clusters.items(): + if mask is None or mask[(y, x)]: + matrix[(y, x)] = self.beacon.trigger_distance - median(values) + else: + matrix[(y, x)] = np.nan + faulty_indexes.append((y, x)) + return matrix, faulty_indexes + + def _load_interpolator(self): + if not self.scipy: + try: + self.scipy = importlib.import_module("scipy") + except ImportError: + msg = ( + "Could not load `scipy`. To install it, simply re-run " + "the Beacon `install.sh` script. This module is required " + "when using faulty regions when bed meshing." + ) + return (True, msg) + if hasattr(self.scipy.interpolate, "RBFInterpolator"): + + def rbf_interp(points, values, faulty): + return self.scipy.interpolate.RBFInterpolator(points, values, 64)( + faulty + ) + + return (False, rbf_interp) + else: + + def linear_interp(points, values, faulty): + return self.scipy.interpolate.griddata( + points, values, faulty, method="linear" + ) + + def _cluster_mean(self, data): + median_count = max(0, int(math.floor(len(data) / 6))) + return float(np.mean(np.sort(data)[median_count : len(data) - median_count])) + + def _interpolate_faulty(self, matrix, faulty_indexes, interpolator): + ys, xs = np.mgrid[0 : matrix.shape[0], 0 : matrix.shape[1]] + points = np.array([ys.flatten(), xs.flatten()]).T + values = matrix.reshape(-1) + good = ~np.isnan(values) + fixed = interpolator(points[good], values[good], faulty_indexes) + matrix[tuple(np.array(faulty_indexes).T)] = fixed + return matrix + + def _check_matrix(self, matrix): + empty_clusters = [] + for yi in range(self.res_y): + for xi in range(self.res_x): + if np.isnan(matrix[(yi, xi)]): + xc = xi * self.step_x + self.min_x + yc = yi * self.step_y + self.min_y + empty_clusters.append(" (%.3f,%.3f)[%d,%d]" % (xc, yc, xi, yi)) + if empty_clusters: + err = ( + "Empty clusters found\n" + "Try increasing mesh cluster_size or slowing down.\n" + "The following clusters were empty:\n" + ) + "\n".join(empty_clusters) + return err + else: + return None + + def _finalize_matrix(self, matrix): + z_offset = None + if self.zero_ref_mode and self.zero_ref_mode[0] == "rri": + rri = self.zero_ref_mode[1] + if rri < 0 or rri >= self.res_x * self.res_y: + rri = None + if rri is not None: + rri_x = rri % self.res_x + rri_y = int(math.floor(rri / self.res_x)) + z_offset = matrix[rri_y][rri_x] + elif self.zero_ref_mode and self.zero_ref_mode[0] == "pos": + z_offset = self.beacon.trigger_distance - self.zero_ref_val + + if z_offset is not None: + matrix = matrix - z_offset + return matrix.tolist() + + def _apply_mesh(self, matrix, gcmd): + params = self.bm.bmc.mesh_config.copy() + params["min_x"] = self.min_x + params["max_x"] = self.max_x + params["min_y"] = self.min_y + params["max_y"] = self.max_y + params["x_count"] = self.res_x + params["y_count"] = self.res_y + try: + mesh = bed_mesh.ZMesh(params) + except TypeError: + mesh = bed_mesh.ZMesh(params, self.profile_name) + try: + mesh.build_mesh(matrix) + except bed_mesh.BedMeshError as e: + raise self.gcode.error(str(e)) + self.bm.set_mesh(mesh) + self.gcode.respond_info("Mesh calibration complete") + if self.profile_name is not None: + self.bm.save_profile(self.profile_name) + + +class Region: + def __init__(self, x_min, x_max, y_min, y_max): + self.x_min = x_min + self.x_max = x_max + self.y_min = y_min + self.y_max = y_max + + def is_point_within(self, x, y): + return (x > self.x_min and x < self.x_max) and ( + y > self.y_min and y < self.y_max + ) + + +def arc_points(cx, cy, r, start_angle, span): + # Angle delta is determined by a max deviation(md) from 0.1mm: + # r * versin(d_a) < md + # versin(d_a) < md/r + # d_a < arcversin(md/r) + # d_a < arccos(1-md/r) + # We then determine how many of these we can fit in exactly + # 90 degrees(rounding up) and then determining the exact + # delta angle. + start_angle = start_angle / 180.0 * math.pi + span = span / 180.0 * math.pi + d_a = math.acos(1 - 0.1 / r) + cnt = int(math.ceil(abs(span) / d_a)) + d_a = span / float(cnt) + + points = [] + for i in range(cnt + 1): + ang = start_angle + d_a * float(i) + x = cx + math.cos(ang) * r + y = cy + math.sin(ang) * r + points.append((x, y)) + + return points + + +def coord_fallback(gcmd, name, parse, def_x, def_y, map=lambda v, d: v): + param = gcmd.get(name, None) + if param is not None: + try: + x, y = [parse(p.strip()) for p in param.split(",", 1)] + return map(x, def_x), map(y, def_y) + except Exception: + raise gcmd.error("Unable to parse parameter '%s'" % (name,)) + else: + return def_x, def_y + + +def float_parse(s): + v = float(s) + if math.isinf(v) or np.isnan(v): + raise ValueError("could not convert string to float: '%s'" % (s,)) + return v + + +def median(samples): + return float(np.median(samples)) + + +def opt_min(a, b): + if a is None: + return b + return min(a, b) + + +def opt_max(a, b): + if a is None: + return b + return max(a, b) + + +GRAVITY = 9.80655 +ACCEL_BYTES_PER_SAMPLE = 6 + +Accel_Measurement = collections.namedtuple( + "Accel_Measurement", ("time", "accel_x", "accel_y", "accel_z") +) + + +class BeaconAccelDummyConfig(object): + error = configfile.error + + def __init__(self, beacon, accel_config): + self.beacon = beacon + self.accel_config = accel_config + + def get_name(self): + return "beacon_accel " + ( + self.accel_config.accel_name or self.accel_config.default_name + ) + + def get_printer(self): + return self.beacon.printer + + +class BeaconAccelConfig(object): + def __init__(self, config, sensor_id): + self.default_scale = config.get("accel_scale", "") + axes = { + "x": (0, 1), + "-x": (0, -1), + "y": (1, 1), + "-y": (1, -1), + "z": (2, 1), + "-z": (2, -1), + } + axes_map = config.getlist("accel_axes_map", ("x", "y", "z"), count=3) + self.axes_map = [] + for a in axes_map: + a = a.strip() + if a not in axes: + raise config.error("Invalid accel_axes_map, unknown axes '%s'" % (a,)) + self.axes_map.append(axes[a]) + + self.default_name = ( + "beacon" if sensor_id.is_unnamed() else "beacon_" + sensor_id.name + ) + self.accel_name = config.get( + "accel_name", None if sensor_id.is_unnamed() else self.default_name + ) + + +class BeaconAccelHelper(object): + def __init__(self, beacon, config, constants): + self.beacon = beacon + self.config = config + + self._api_dump = APIDumpHelper( + beacon.printer, + lambda: self._start_streaming() or True, + lambda _: self._stop_streaming(), + self._api_update, + ) + beacon.id.register_endpoint("beacon/dump_accel", self._handle_req_dump) + cmd_helper = adxl345.AccelCommandHelper( + BeaconAccelDummyConfig(beacon, config), self + ) + if config.accel_name is None: + try: + cmd_helper.register_commands(None) + except beacon.printer.config_error: + pass + + self._stream_en = 0 + self._raw_samples = [] + self._last_raw_sample = (0, 0, 0) + self._sample_lock = threading.Lock() + + beacon.compat_mcu_register_response( + self._handle_accel_data, + "beacon_accel_data start_clock=%u delta_clock=%u data=%*s", + ) + beacon.compat_mcu_register_response( + self._handle_accel_state, "beacon_accel_state en=%c err=%c" + ) + + self.reinit(constants) + + def reinit(self, constants): + bits = constants.get("BEACON_ACCEL_BITS") + self._clip_values = (2 ** (bits - 1) - 1, -(2 ** (bits - 1))) + + self.accel_stream_cmd = self.beacon._mcu.lookup_command( + "beacon_accel_stream en=%c scale=%c", cq=self.beacon.cmd_queue + ) + # Ensure streaming mode is stopped + self.accel_stream_cmd.send([0, 0]) + + self._scales = self._fetch_scales(constants) + self._scale = self._select_scale() + logging.info("Selected Beacon accelerometer scale %s", self._scale["name"]) + + def _fetch_scales(self, constants): + enum = self.beacon._mcu.get_enumerations().get("beacon_accel_scales", None) + if enum is None: + return {} + + scales = {} + self.default_scale_name = self.config.default_scale + first_scale_name = None + for name, id in enum.items(): + try: + scale_val_name = "BEACON_ACCEL_SCALE_%s" % (name.upper(),) + scale_val_str = constants.get(scale_val_name) + scale_val = float(scale_val_str) + except Exception: + logging.error( + "Beacon accelerometer scale %s could not be processed", name + ) + scale_val = 1 # Values will be weird, but scale will work + + if id == 0: + first_scale_name = name + scales[name] = {"name": name, "id": id, "scale": scale_val} + + if not self.default_scale_name: + if first_scale_name is None: + logging.error("Could not determine default Beacon accelerometer scale") + else: + self.default_scale_name = first_scale_name + elif self.default_scale_name not in scales: + logging.error( + "Default Beacon accelerometer scale '%s' not found, using '%s'", + self.default_scale_name, + first_scale_name, + ) + self.default_scale_name = first_scale_name + + return scales + + def _select_scale(self): + scale = self._scales.get(self.default_scale_name, None) + if scale is None: + return {"name": "unknown", "id": 0, "scale": 1} + return scale + + def _handle_accel_data(self, params): + with self._sample_lock: + if self._stream_en: + self._raw_samples.append(params) + else: + self.accel_stream_cmd.send([0, 0]) + + def _handle_accel_state(self, params): + pass + + def _handle_req_dump(self, web_request): + cconn = self._api_dump.add_web_client( + web_request, + lambda buffer: list( + itertools.chain(*map(lambda data: data["data"], buffer)) + ), + ) + cconn.send({"header": ["time", "x", "y", "z"]}) + + # Internal helpers + + def _start_streaming(self): + if self._stream_en == 0: + self._raw_samples = [] + self.accel_stream_cmd.send([1, self._scale["id"]]) + self._stream_en += 1 + + def _stop_streaming(self): + self._stream_en -= 1 + if self._stream_en == 0: + self._raw_samples = [] + self.accel_stream_cmd.send([0, 0]) + + def _process_samples(self, raw_samples, last_sample): + raw = last_sample + (xp, xs), (yp, ys), (zp, zs) = self.config.axes_map + scale = self._scale["scale"] * GRAVITY + xs, ys, zs = xs * scale, ys * scale, zs * scale + + errors = 0 + samples = [] + + def process_value(low, high, last_value): + raw = high << 8 | low + if raw == 0x7FFF: + # Clipped value + return self._clip_values[0 if last_value >= 0 else 1] + return raw - ((high & 0x80) << 9) + + for sample in raw_samples: + tstart = self.beacon._clock32_to_time(sample["start_clock"]) + tend = self.beacon._clock32_to_time( + sample["start_clock"] + sample["delta_clock"] + ) + data = bytearray(sample["data"]) + count = int(len(data) / ACCEL_BYTES_PER_SAMPLE) + dt = (tend - tstart) / (count - 1) + for idx in range(0, count): + base = idx * ACCEL_BYTES_PER_SAMPLE + d = data[base : base + ACCEL_BYTES_PER_SAMPLE] + dxl, dxh, dyl, dyh, dzl, dzh = d + raw = ( + process_value(dxl, dxh, raw[0]), + process_value(dyl, dyh, raw[1]), + process_value(dzl, dzh, raw[2]), + ) + if raw[0] is None or raw[1] is None or raw[2] is None: + errors += 1 + samples.append(None) + else: + samples.append( + ( + tstart + dt * idx, + raw[xp] * xs, + raw[yp] * ys, + raw[zp] * zs, + ) + ) + return (samples, errors, raw) + + # APIDumpHelper callbacks + + def _api_update(self, dump_helper, eventtime): + with self._sample_lock: + raw_samples = self._raw_samples + self._raw_samples = [] + samples, errors, last_raw_sample = self._process_samples( + raw_samples, self._last_raw_sample + ) + if len(samples) == 0: + return + self._last_raw_sample = last_raw_sample + dump_helper.buffer.append( + { + "data": samples, + "errors": errors, + "overflows": 0, + } + ) + + # Accelerometer public interface + + def start_internal_client(self): + cli = AccelInternalClient(self.beacon.printer) + self._api_dump.add_client(cli._handle_data) + return cli + + def read_reg(self, reg): + raise self.beacon.printer.command_error("Not supported") + + def set_reg(self, reg, val, minclock=0): + raise self.beacon.printer.command_error("Not supported") + + def is_measuring(self): + return self._stream_en > 0 + + +class AccelInternalClient: + def __init__(self, printer): + self.printer = printer + self.toolhead = printer.lookup_object("toolhead") + self.is_finished = False + self.request_start_time = self.request_end_time = ( + self.toolhead.get_last_move_time() + ) + self.msgs = [] + self.samples = [] + + def _handle_data(self, msgs): + if self.is_finished: + return False + if len(self.msgs) >= 10000: # Limit capture length + return False + self.msgs.extend(msgs) + return True + + # AccelQueryHelper interface + + def finish_measurements(self): + self.request_end_time = self.toolhead.get_last_move_time() + self.toolhead.wait_moves() + self.is_finished = True + + def has_valid_samples(self): + for msg in self.msgs: + data = msg["data"] + first_sample_time = data[0][0] + last_sample_time = data[-1][0] + if ( + first_sample_time > self.request_end_time + or last_sample_time < self.request_start_time + ): + continue + return True + return False + + def get_samples(self): + if not self.msgs: + return self.samples + + total = sum([len(m["data"]) for m in self.msgs]) + count = 0 + self.samples = samples = [None] * total + for msg in self.msgs: + for samp_time, x, y, z in msg["data"]: + if samp_time < self.request_start_time: + continue + if samp_time > self.request_end_time: + break + samples[count] = Accel_Measurement(samp_time, x, y, z) + count += 1 + del samples[count:] + return self.samples + + def write_to_file(self, filename): + def do_write(): + try: + os.nice(20) + except Exception: + pass + with open(filename, "w") as f: + f.write("#time,accel_x,accel_y,accel_z\n") + samples = self.samples or self.get_samples() + for t, accel_x, accel_y, accel_z in samples: + f.write("%.6f,%.6f,%.6f,%.6f\n" % (t, accel_x, accel_y, accel_z)) + + write_proc = multiprocessing.Process(target=do_write) + write_proc.daemon = True + write_proc.start() + + +class APIDumpHelper: + def __init__(self, printer, start, stop, update): + self.printer = printer + self.start = start + self.stop = stop + self.update = update + self.interval = 0.05 + self.clients = [] + self.stream = None + self.timer = None + self.buffer = [] + + def _start_stop(self): + if not self.stream and self.clients: + self.stream = self.start() + reactor = self.printer.get_reactor() + self.timer = reactor.register_timer( + self._process, reactor.monotonic() + self.interval + ) + elif self.stream is not None and not self.clients: + self.stop(self.stream) + self.stream = None + self.printer.get_reactor().unregister_timer(self.timer) + self.timer = None + + def _process(self, eventtime): + if self.update is not None: + self.update(self, eventtime) + if self.buffer: + for cb in list(self.clients): + if not cb(self.buffer): + self.clients.remove(cb) + self._start_stop() + self.buffer = [] + return eventtime + self.interval + + def add_client(self, client): + self.clients.append(client) + self._start_stop() + + def add_web_client(self, web_request, formatter=lambda v: v): + cconn = web_request.get_client_connection() + template = web_request.get_dict("response_template", {}) + + def cb(items): + if cconn.is_closed(): + return False + tmp = dict(template) + tmp["params"] = formatter(items) + cconn.send(tmp) + return True + + self.add_client(cb) + return cconn + + +class BeaconTracker: + def __init__(self, config, printer): + self.config = config + self.printer = printer + self.sensors = {} + self.gcodes = {} + self.endpoints = {} + self.gcode = printer.lookup_object("gcode") + self.webhooks = printer.lookup_object("webhooks") + + def get_status(self, eventtime): + return {"sensors": list(self.sensors.keys())} + + def home_dir(self): + return os.path.dirname(os.path.realpath(__file__)) + + def add_sensor(self, name): + if name is None: + cfg = self.config.getsection("beacon") + else: + if not name.islower(): + raise self.config.error( + "Beacon sensor name must be all lower case, sensor name '%s' is not valid" + % (name,) + ) + cfg = self.config.getsection("beacon sensor " + name) + self.sensors[name] = sensor = BeaconProbe(cfg, BeaconId(name, self)) + coil_name = "beacon_coil" if name is None else "beacon_%s_coil" % (name,) + temp = BeaconTempWrapper(sensor) + self.printer.add_object("temperature_sensor " + coil_name, temp) + pheaters = self.printer.load_object(self.config, "heaters") + pheaters.available_sensors.append("temperature_sensor " + coil_name) + return sensor + + def get_or_add_sensor(self, name): + if name in self.sensors: + return self.sensors[name] + else: + return self.add_sensor(name) + + def register_gcode_command(self, sensor, cmd, func, desc): + if cmd not in self.gcodes: + handlers = self.gcodes[cmd] = {} + self.gcode.register_command( + cmd, lambda gcmd: self.dispatch_gcode(handlers, gcmd), desc=desc + ) + self.gcodes[cmd][sensor] = func + + def dispatch_gcode(self, handlers, gcmd): + sensor = gcmd.get("SENSOR", "") + if sensor == "": + sensor = None + handler = handlers.get(sensor, None) + if not handler: + if sensor is None: + raise gcmd.error( + "No default Beacon registered, provide SENSOR= option to select specific sensor." + ) + else: + raise gcmd.error( + "Requested sensor '%s' not found, specify a valid sensor." + % (sensor,) + ) + handler(gcmd) + + def register_endpoint(self, sensor, path, callback): + if path not in self.endpoints: + self.webhooks.register_endpoint(path, self.dispatch_webhook) + self.endpoints[path] = {} + self.endpoints[path][sensor] = callback + + def dispatch_webhook(self, req): + handlers = self.endpoints[req.method] + sensor = req.get("sensor", "") + if sensor == "": + sensor = None + handler = handlers.get(sensor, None) + if not handler: + if sensor is None: + raise req.error( + "No default Beacon registered, provide 'sensor' option to specify sensor." + ) + else: + raise req.error( + "Requested sensor '%s' not found, specify a valid or no sensor to use default" + % (sensor,) + ) + handler(req) + + +class BeaconId: + def __init__(self, name, tracker): + self.name = name + self.tracker = tracker + + def is_unnamed(self): + return self.name is None + + def register_command(self, cmd, func, desc): + self.tracker.register_gcode_command(self.name, cmd, func, desc) + + def register_endpoint(self, path, callback): + self.tracker.register_endpoint(self.name, path, callback) + + +def get_beacons(config): + printer = config.get_printer() + beacons = printer.lookup_object("beacons", None) + if beacons is None: + beacons = BeaconTracker(config, printer) + printer.add_object("beacons", beacons) + return beacons + + +def load_config(config): + return get_beacons(config).get_or_add_sensor(None) + + +def load_config_prefix(config): + beacons = get_beacons(config) + sensor = None + secname = config.get_name() + parts = secname[7:].split() + + if len(parts) != 0 and parts[0] == "sensor": + if len(parts) < 2: + raise config.error("Missing Beacon sensor name") + sensor = parts[1] + parts = parts[2:] + + beacon = beacons.get_or_add_sensor(sensor) + + if len(parts) == 0: + return beacon + + if parts[0] == "model": + if len(parts) != 2: + raise config.error("Missing Beacon model name in section '%s'" % (secname,)) + name = parts[1] + model = BeaconModel.load(name, config, beacon) + beacon._register_model(name, model) + return model + else: + raise config.error("Unknown beacon config directive '%s'" % (secname,)) From 9e4ef773d40fec16bab0c84c533cb54a482e1261 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Fri, 12 Jun 2026 10:38:22 +0100 Subject: [PATCH 11/19] Simplify boudary checking --- klippy/extras/bed_custom_bound.py | 63 ++++++------------------------- klippy/extras/bucket.py | 18 +-------- 2 files changed, 13 insertions(+), 68 deletions(-) diff --git a/klippy/extras/bed_custom_bound.py b/klippy/extras/bed_custom_bound.py index 03461a86dbda..5926bebaf2a1 100644 --- a/klippy/extras/bed_custom_bound.py +++ b/klippy/extras/bed_custom_bound.py @@ -123,58 +123,19 @@ def move_to_park(self): [self.park[0], self.park[1]], self.travel_speed ) - def check_boundary_limits( - self, position: typing.Tuple[float, float], bound_type: str = "default" - ): - if not self.toolhead or not position: - return - - min_limit_x = max_limit_x = min_limit_y = max_limit_y = None - - if ( - bound_type == "default" - and self.default_limits_x - and self.default_limits_y - ): - min_limit_x = float(self.default_limits_x[0]) - max_limit_x = float(self.default_limits_x[1]) - min_limit_y = float(self.default_limits_y[0]) - max_limit_y = float(self.default_limits_y[1]) - elif bound_type == "current": - kin = self.toolhead.get_kinematics() - min_limit_x = float(kin.limits[0][0]) - max_limit_x = float(kin.limits[0][1]) - min_limit_y = float(kin.limits[1][0]) - max_limit_y = float(kin.limits[1][1]) - elif ( - bound_type == "custom" - and self.custom_boundary_x - and self.custom_boundary_y - ): - min_limit_x = float(self.custom_boundary_x[0]) - max_limit_x = float(self.custom_boundary_x[1]) - min_limit_y = float(self.custom_boundary_y[0]) - max_limit_y = float(self.custom_boundary_y[1]) - - if None in (min_limit_x, max_limit_x, min_limit_y, max_limit_y): - return None - - va = all([min_limit_x, max_limit_x, min_limit_y, max_limit_y]) - if va : - _limits = { - "x": bool(min_limit_x < position[0] < max_limit_x), - "y": bool(min_limit_y < position[1] < max_limit_y), - } - return _limits - return None - - # _limits = {} - # if min_limit_x < position[0] or max_limit_x < position[0]: - # _limits.update({"x": False}) - - # if min_limit_y < position[1] or max_limit_y < position[1]: - # _limits.update({"y": False}) + def check_boundary_limits(self, position: tuple[float, float]): + """Checks if a point is outside the limits of the custom bound, + and inside the machine limits""" + self.printer.command_error( + "Provided position is outside of the printers stepper limits" + ) + _limits = {"x": False, "y": False} + if self.default_limits_x[0] <= position[0] < self.custom_boundary_x[0]: + _limits["x"] = True + if self.default_limits_y[0] <= position[1] < self.custom_boundary_y[0]: + _limits["y"] = False + return all(_limits) def get_status(self, eventtime=None): """Get the status of the current boundary""" diff --git a/klippy/extras/bucket.py b/klippy/extras/bucket.py index c6f6dd724bc6..ab522fe4a866 100644 --- a/klippy/extras/bucket.py +++ b/klippy/extras/bucket.py @@ -47,21 +47,7 @@ def move_to_bucket(self, split: typing.Optional["bool"] = False): return try: if self.custom_bed_bound_object: - _conf_bound = ( - self.custom_bed_bound_object.check_boundary_limits( - position=( - self.bucket_position[0], - self.bucket_position[1], - ) - ) - ) - if ( - not _conf_bound["x"] or not _conf_bound["y"] - ) and self.custom_bed_bound_object.get_status().get( - "status", "" - ) == "custom": - self.custom_bed_bound_object.restore_default_boundary() - + self.custom_bed_bound_object.restore_default_boundary() if not split: self.toolhead.manual_move( @@ -78,8 +64,6 @@ def move_to_bucket(self, split: typing.Optional["bool"] = False): self.travel_speed, ) - self.toolhead.wait_moves() - if ( self.custom_bed_bound_object and self.custom_bed_bound_object.get_status().get("status", "") From 57c8d9ec24ad1ab83a325b6b9c34ccb2070428ea Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Mon, 15 Jun 2026 14:51:39 +0100 Subject: [PATCH 12/19] Bugfix: default printer boundary Setting the custom boundary on the pritner would overwrite the default printer boundary (discovered after homing) when two SET_CUSTOM_BOUNDARY gcode would be issue one after the other, this would make the default/custom boudaries change and lose track of the default boundaries --- klippy/extras/bed_custom_bound.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/klippy/extras/bed_custom_bound.py b/klippy/extras/bed_custom_bound.py index 5926bebaf2a1..09bd97438866 100644 --- a/klippy/extras/bed_custom_bound.py +++ b/klippy/extras/bed_custom_bound.py @@ -101,10 +101,11 @@ def set_custom_boundary(self, eventtime=None): ) kin = self.toolhead.get_kinematics() - self.default_limits_x, self.default_limits_y = ( - kin.limits[0], - kin.limits[1], - ) + if not self.default_limits_x and not self.default_limits_y: + self.default_limits_x, self.default_limits_y = ( + kin.limits[0], + kin.limits[1], + ) kin.limits[0] = ( float(self.custom_boundary_x[0]), From ec4a7876e4d313d2c0e1cc97b7b1b340380d07ac Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Tue, 23 Jun 2026 12:13:40 +0100 Subject: [PATCH 13/19] Add: update readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 89c13d56e0a0..279beaeddd8f 100644 --- a/README.md +++ b/README.md @@ -18,5 +18,7 @@ depend on the generous support from our -The current fork of the Klipper firmware project includes [Happy-Hare](https://github.com/moggieuk/Happy-Hare). +This fork of the Klipper firmware project includes the following software: + +- [Happy-Hare](https://github.com/moggieuk/Happy-Hare) : Universal MMU for Klipper. From 98cb7eab5f808ff08d344bf7585cf2282822ea25 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Wed, 24 Jun 2026 13:29:35 +0100 Subject: [PATCH 14/19] Del: agents.md --- AGENTS.md | 198 ------------------------------------------------------ 1 file changed, 198 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index c0038fcae609..000000000000 --- a/AGENTS.md +++ /dev/null @@ -1,198 +0,0 @@ -# AGENTS.md - Klipper Development Guide - -This file provides guidance for AI agents working on the Klipper 3D printer firmware. - -## Project Overview - -Klipper is a 3D printer firmware combining a Raspberry Pi (host) with microcontrollers. This is a Blocks fork with additional modules (belay, filament cutter, load/unload, bucket, bed boundaries). - -## Directory Structure - -| Directory | Purpose | -|-----------|---------| -| `klippy/` | Host-side Python code (runs on Raspberry Pi) | -| `klippy/extras/` | Optional modules (~100+ plugins) | -| `klippy/kinematics/` | Printer motion models (cartesian, delta, corexy, etc.) | -| `src/` | Microcontroller firmware in C | -| `config/` | Printer board configurations (150+ boards) | -| `scripts/` | Build/flash/utilities scripts | -| `test/klippy/` | Test configuration files | -| `docs/` | Documentation | -| `lib/` | External dependencies (CMSIS, HAL, SDKs) | - -## Build Commands - -### Firmware Build -```bash -# Configure (select board via menu) -make menuconfig - -# Build firmware -make - -# Clean build artifacts -make clean - -# Full clean (removes config too) -make distclean - -# Verbose build (see actual commands) -make V=1 -``` - -### Running Tests - -```bash -# Run all tests -python3 scripts/test_klippy.py test/klippy/*.test - -# Run a single test (verbose) -python3 scripts/test_klippy.py -v test/klippy/commands.test - -# Run single test, keep temp files -python3 scripts/test_klippy.py -k test/klippy/commands.test -``` - -Test files are `.test` files in `test/klippy/` that reference: -- `DICTIONARY` - MCU dictionary file -- `CONFIG` - Printer config file -- G-code commands to execute - -## Code Style - Python (klippy/) - -### General Guidelines -- **Python 2/3 compatible** (shebang uses `python2`, runs on Python 3) -- No type annotations (keep compatibility) -- 4-space indentation -- Maximum line length: ~80-100 characters (flexible) -- Use `logging` module for logging, not print statements - -### Imports -```python -# Standard library first, then third-party, then local -import os -import re -import logging -import collections - -import util, reactor, queuelogger -import gcode, configfile, pins -``` - -### Naming Conventions -- **Classes**: CamelCase (e.g., `class Printer:`, `class GCodeDispatch:`) -- **Functions/methods**: snake_case (e.g., `def get_position():`, `def setup_registers():`) -- **Constants**: UPPER_CASE (e.g., `MAX_SPEED = 1000`) -- **Private methods**: prefix with underscore (e.g., `def _connect(self):`) - -### Error Handling -```python -# Configuration errors -raise self.config_error("Error message: %s" % (value,)) - -# G-code command errors -raise gcode.CommandError("Error processing command") - -# Use logging for errors -logging.error("Error message: %s" % (detail,)) -logging.info("Informational message") -logging.debug("Debug details") -``` - -### Class Structure Pattern -```python -class Printer: - config_error = configfile.error - command_error = gcode.CommandError - - def __init__(self, main_reactor, bglogger, start_args): - self.bglogger = bglogger - self.start_args = start_args - self.reactor = main_reactor - - def get_reactor(self): - return self.reactor -``` - -### Config File Pattern -```python -class MyModule: - def __init__(self, config): - self.printer = config.get_printer() - self.name = config.get_name() - # Register handlers - self.printer.register_event_handler("klippy:ready", self.handle_ready) -``` - -## Code Style - C (src/) - -### General Guidelines -- **C11 standard** with GNU extensions (`-std=gnu11`) -- 4-space indentation -- K&R brace style -- Comment style: `// Single line` or `/* Multi-line */` - -### Includes (order matters) -```c -#include "autoconf.h" // CONFIG_* - generated -#include "basecmd.h" // oid_alloc -#include "board/gpio.h" // gpio_out_write -#include "command.h" // DECL_COMMAND -#include "sched.h" // struct timer -``` - -### Naming Conventions -- **Structs**: lowercase with underscores (e.g., `struct stepper_move`) -- **Enums**: UPPER_CASE with prefix (e.g., `enum { MF_DIR=1<<0 }`) -- **Functions**: snake_case (e.g., `stepper_load_next()`) -- **Macros**: UPPER_CASE (e.g., `#define HAVE_EDGE_OPTIMIZATION 1`) - -### Code Patterns -```c -// Struct definition -struct stepper { - struct timer time; - uint32_t interval; - struct gpio_out step_pin; -}; - -// Function with error handling -static uint_fast8_t -stepper_load_next(struct stepper *s) -{ - if (move_queue_empty(&s->mq)) { - s->count = 0; - return SF_DONE; - } - // ... implementation -} -``` - -## Configuration System - -Uses Kconfig (Linux kernel style): -- `make menuconfig` opens interactive configurator -- Board configs in `config/` directory -- Creates `.config` file and `out/autoconf.h` - -## VSCode Settings - -Project has `.vscode/settings.json` with pylint: -```json -{ - "pylint.args": ["--disable=C0115", "--disable=R0902"] -} -``` -- C0115: Missing class docstring -- R0902: Too many instance attributes - -## Key Files to Know - -- `klippy/klippy.py` - Main entry point -- `klippy/gcode.py` - G-code parser -- `klippy/toolhead.py` - Motion planning -- `klippy/configfile.py` - Config parsing -- `klippy/mcu.py` - MCU communication -- `src/sched.c` - Task scheduler -- `src/command.c` - Command protocol -- `src/stepper.c` - Stepper motor control From a223c01fbcf36346987e7d7b604327190a5e32a3 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Wed, 24 Jun 2026 13:30:15 +0100 Subject: [PATCH 15/19] Del: fil plan --- docs/Generated-filament_manager_plan.md | 122 ------------------------ 1 file changed, 122 deletions(-) delete mode 100644 docs/Generated-filament_manager_plan.md diff --git a/docs/Generated-filament_manager_plan.md b/docs/Generated-filament_manager_plan.md deleted file mode 100644 index 26681ea7e749..000000000000 --- a/docs/Generated-filament_manager_plan.md +++ /dev/null @@ -1,122 +0,0 @@ -# Filament Manager Implementation Plan - -## Discussion Summary - -### 1. Enum vs Literal for States -- Current code uses `typing.Literal["loading", "loaded", "unloaded", "unloading", "unknown"]` -- **Decision**: Use `enum.Enum` (already implemented as `FilamentStates`) -- Benefits: Proper values for comparison, IDE autocomplete, no typo bugs - -### 2. Temperature Constants -- Current code has: `PLA_TEMPERATURE`, `PETG_TEMPERATURE`, `ABS_TEMPERATURE`, `NYLON_TEMPERATURE`, `DEFAULT_TEMPERATURE` -- **Decision**: Keep as constants (current approach is fine) -- Reasoning: They're truly constants, no behavior, Python idiom - ---- - -## Requirements - -1. Multiple extruders support - load/unload each head independently -2. Sensors (switch sensors, filament_motion_sensor, cutter_sensor) for detecting filament state -3. Configuration per extruder with optional sensors -4. Status via `get_status(eventtime)`: loading/unloading/loaded/unloaded/unknown -5. Generic sensor support - any switch sensor can be used -6. Purge at end of load (move to bucket position) -7. Timeout fallback if no sensors -8. State persistence via variables.py - ---- - -## Key Design Decisions - -### Temperature -- **Option B**: G-code parameter `LOAD_FILAMENT EXTRUDER=extruder FILAMENT=PLA` -- Use `FILAMENT_TEMPERATURES` dict lookup - -### Bucket Integration -- Use existing `bucket.py` functionality -- Call `self.bucket.move_to_bucket()` during purge - -### Sensors Configuration -```ini -[filament_manager extruder] -sensors: my_switch_sensor, my_motion_sensor, cutter -``` - -### Initial State -- Default to `unknown` -- Load from `save_variables` on startup -- Save state after each load/unload operation - -### Error Handling -- Use `self.printer.command_error("")` -- Stop procedure, don't continue - ---- - -## Class Structure - -``` -FilamentManager (main config + G-code commands) - | - +-- FilamentMotions (per-extruder controller) - | - +-- SensorChecker (generic sensor wrapper) - +-- ExtruderMotions (heating, movement) -``` - ---- - -## Events Emitted - -- `filament_manager:loading` -- `filament_manager:loaded` -- `filament_manager:unloading` -- `filament_manager:unloaded` -- `filament_manager:error` - ---- - -## G-code Commands - -```bash -LOAD_FILAMENT EXTRUDER=extruder FILAMENT=PLA -UNLOAD_FILAMENT EXTRUDER=extruder -QUERY_FILAMENT [EXTRUDER=extruder] -``` - ---- - -## Configuration Example - -```ini -[filament_manager] -default_timeout: 30 -purge_count: 3 -purge_length: 5 -purge_speed: 5 -load_speed: 10 -unload_speed: 10 -travel_speed: 100 -save_variables: True - -[filament_manager extruder] -sensors: my_switch_sensor, my_motion_sensor -load_timeout: 20 -unload_timeout: 15 -``` - ---- - -## Implementation Issues Fixed - -| Issue | Fix | -|-------|-----| -| `typing.Literal` used as values | Use `FilamentStates` enum properly | -| Incomplete load/unload methods | Fully implemented | -| No state persistence | Added `_save_state()` / `_load_saved_state()` | -| No bucket integration | Added `self.bucket.move_to_bucket()` on purge | -| No filament type/temperature | Added `FILAMENT_TEMPERATURES` dict | -| No purge logic | Added `_purge()` timer and purge_count | -| Broken sensor callbacks | Fixed trigger handlers | -| Incomplete status | `get_status()` returns proper dict | From 3d821f354811e4a34c813d0faeb595ed53702c8a Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Fri, 26 Jun 2026 11:27:42 +0100 Subject: [PATCH 16/19] Ref: simplify setting custom bound --- klippy/extras/bucket.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/klippy/extras/bucket.py b/klippy/extras/bucket.py index 49423683f8cf..0ad9262abb17 100644 --- a/klippy/extras/bucket.py +++ b/klippy/extras/bucket.py @@ -63,11 +63,7 @@ def move_to_bucket(self, split: typing.Optional["bool"] = False): self.travel_speed, ) - if ( - self.custom_bed_bound_object - and self.custom_bed_bound_object.get_status().get("status", "") - == "default" - ): + if self.custom_bed_bound_object: self.custom_bed_bound_object.set_custom_boundary() except Exception as e: raise BucketMoveError( From 0ef4d0b2ea83b235e339311a117374a0cf0f2ccf Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Wed, 8 Jul 2026 13:54:33 +0100 Subject: [PATCH 17/19] Add debug messages --- klippy/extras/bed_custom_bound.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/klippy/extras/bed_custom_bound.py b/klippy/extras/bed_custom_bound.py index 09bd97438866..46049faeee83 100644 --- a/klippy/extras/bed_custom_bound.py +++ b/klippy/extras/bed_custom_bound.py @@ -18,6 +18,7 @@ def __init__(self, config): self.reactor = self.printer.get_reactor() self.gcode = self.printer.lookup_object("gcode") self.printer.register_event_handler("klippy:ready", self.handle_ready) + self.debug = config.getint("debug",default=0) self.custom_boundary_x = None if config.getfloatlist("custom_boundary_x", None, count=2) is not None: self.custom_boundary_x = config.getfloatlist( @@ -74,10 +75,10 @@ def restore_default_boundary(self, eventtime=None): return if not self.default_limits_x or not self.default_limits_y: return - - self.gcode.respond_info( - f"[CUSTOM BED BOUNDARY] Restoring printer boundary limits" - ) + if self.debug: + self.gcode.respond_info( + f"[CUSTOM BED BOUNDARY] Restoring printer boundary limits" + ) kin = self.toolhead.get_kinematics() kin.limits[0] = ( @@ -96,9 +97,10 @@ def set_custom_boundary(self, eventtime=None): return if not self.custom_boundary_x or not self.custom_boundary_y: return - self.gcode.respond_info( - "[CUSTOM BED BOUNDARY] Setting specified custom boundary" - ) + if self.debug: + self.gcode.respond_info( + "[CUSTOM BED BOUNDARY] Setting specified custom boundary" + ) kin = self.toolhead.get_kinematics() if not self.default_limits_x and not self.default_limits_y: From 3c64dcf98a4b79429d75f72bcb769911dea8663e Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Wed, 15 Jul 2026 10:29:56 +0100 Subject: [PATCH 18/19] beacon module disclosure --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 279beaeddd8f..851fd6b667d7 100644 --- a/README.md +++ b/README.md @@ -21,4 +21,4 @@ depend on the generous support from our This fork of the Klipper firmware project includes the following software: - [Happy-Hare](https://github.com/moggieuk/Happy-Hare) : Universal MMU for Klipper. - +- [Beacon Module](https://beacon3d.com): Eddy current surface scanner From 263a818dbb64532a159a86341f2cc88a0c2d6189 Mon Sep 17 00:00:00 2001 From: HugoCLSC Date: Wed, 15 Jul 2026 14:11:02 +0100 Subject: [PATCH 19/19] Fix check bounds method --- klippy/extras/bed_custom_bound.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/klippy/extras/bed_custom_bound.py b/klippy/extras/bed_custom_bound.py index 46049faeee83..b93998e6dfcd 100644 --- a/klippy/extras/bed_custom_bound.py +++ b/klippy/extras/bed_custom_bound.py @@ -1,6 +1,3 @@ -import typing - - ############################### # Example configuration # @@ -18,7 +15,7 @@ def __init__(self, config): self.reactor = self.printer.get_reactor() self.gcode = self.printer.lookup_object("gcode") self.printer.register_event_handler("klippy:ready", self.handle_ready) - self.debug = config.getint("debug",default=0) + self.debug = config.getint("debug", default=0) self.custom_boundary_x = None if config.getfloatlist("custom_boundary_x", None, count=2) is not None: self.custom_boundary_x = config.getfloatlist( @@ -127,18 +124,15 @@ def move_to_park(self): ) def check_boundary_limits(self, position: tuple[float, float]): - """Checks if a point is outside the limits of the custom bound, - and inside the machine limits""" - - self.printer.command_error( - "Provided position is outside of the printers stepper limits" - ) - _limits = {"x": False, "y": False} - if self.default_limits_x[0] <= position[0] < self.custom_boundary_x[0]: - _limits["x"] = True - if self.default_limits_y[0] <= position[1] < self.custom_boundary_y[0]: - _limits["y"] = False - return all(_limits) + """Checks if a position is within the current kinematic limits.""" + if not self.toolhead or not position: + return {"x": True, "y": True} + kin = self.toolhead.get_kinematics() + _limits = { + "x": kin.limits[0][0] <= position[0] <= kin.limits[0][1], + "y": kin.limits[1][0] <= position[1] <= kin.limits[1][1], + } + return _limits def get_status(self, eventtime=None): """Get the status of the current boundary"""