├── .github ├── CONTRIBUTING.md └── workflows │ └── stale.yml ├── .gitignore ├── LICENSE ├── README.md ├── components └── mmu_server.py ├── config ├── addons │ ├── README.md │ ├── blobifier.cfg │ ├── blobifier_hw.cfg │ ├── mmu_eject_buttons.cfg │ ├── mmu_eject_buttons_hw.cfg │ ├── mmu_erec_cutter.cfg │ └── mmu_erec_cutter_hw.cfg ├── base │ ├── mmu.cfg │ ├── mmu_cut_tip.cfg │ ├── mmu_form_tip.cfg │ ├── mmu_hardware.cfg │ ├── mmu_leds.cfg │ ├── mmu_macro_vars.cfg │ ├── mmu_parameters.cfg │ ├── mmu_parameters.cfg.rs │ ├── mmu_parameters.cfg.ss │ ├── mmu_parameters.cfg.vs │ ├── mmu_purge.cfg │ ├── mmu_sequence.cfg │ ├── mmu_software.cfg │ └── mmu_state.cfg ├── mmu_vars.cfg └── optional │ ├── client_macros.cfg │ └── mmu_menu.cfg ├── extras ├── .pylintrc ├── mmu │ ├── __init__.py │ ├── mmu.py │ ├── mmu_logger.py │ ├── mmu_selector.py │ ├── mmu_sensor_manager.py │ ├── mmu_shared.py │ ├── mmu_test.py │ └── mmu_utils.py ├── mmu_encoder.py ├── mmu_espooler.py ├── mmu_led_effect.py ├── mmu_leds.py ├── mmu_machine.py ├── mmu_sensors.py └── mmu_servo.py ├── install.sh ├── installer-dev ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yaml └── entrypoint.sh ├── moonraker_update.txt ├── pin_defs └── test ├── __init__.py ├── components ├── __init__.py └── test_mmu_server.py ├── runner.sh └── support ├── no_toolchange.orig.gcode └── toolchange.orig.gcode /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute to Happy Hare 2 | 3 | #### **Do you need help with your setup?** 4 | 5 | * 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. 6 | 7 | #### **Did you find a bug?** 8 | 9 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/moggieuk/Happy-Hare/issues). 10 | 11 | * 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**. 12 | 13 | #### **Did you write a patch that fixes a bug?** 14 | 15 | * Open a new GitHub pull request with the patch. 16 | 17 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 18 | 19 | #### **Do you intend to add a new feature or change an existing one?** 20 | 21 | * 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. 22 | 23 | * Changes that break existing setups will probably be rejected. 24 | 25 | Thanks! :heart: :heart: :heart: 26 | 27 | - Moggieuk 28 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '0 */4 * * *' 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | permissions: 17 | issues: write 18 | pull-requests: write 19 | 20 | steps: 21 | - uses: actions/stale@v5 22 | with: 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | days-before-pr-stale: 180 25 | days-before-issue-stale: 30 26 | days-before-close: 14 27 | remove-stale-when-updated: true 28 | any-of-labels: believe fixed / answered, more info needed, wontfix, incomplete 29 | 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." 30 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 31 | stale-issue-label: 'stale' 32 | 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." 33 | close-pr-message: "This PR was closed because it has been inactive for 14 days since being marked as stale." 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | my_*.cfg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Happy Hare 3 |

Happy Hare

4 |

5 | 6 |

7 | Universal Automated Filament Changer / MMU driver for Klipper 8 |

9 | 10 |

11 | 16 | 17 |   18 | 19 |   20 | 21 |   22 | 23 |   24 | 25 |
26 | 27 |   28 | 34 |

35 | 36 | 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**, and various custom designs. 37 | 38 | 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, at least until Mainsail integration is completed. Extensive documentation is available in the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki). 39 | 40 | 41 | 42 | 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: 43 | 44 | - **Additional MMU Support:** _Striving for inclusivity—support for Prusa MMU, KMS, Open AMS, Pico MMU, and others is in progress_ 45 | - **Mainsail/Fluidd Plugin:** _While the KlipperScreen extension offers a rich user interface, many users have requested similar functionality for Mainsail. This integration is coming soon!_ 46 | 47 | 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 17,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. Thank you! 48 |

49 | 50 |
51 | 52 | **Don't forget to join the dedicated Happy Hare community forum here: https://discord.gg/aABQUjkZPk** 53 | 54 |
55 | 56 | ## ![#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: 57 | 58 | - Support almost any brand of MMU (including mods) or custom monsters: 59 | - ERCF 60 | - Tradrack 61 | - Box Turtle 62 | - Angry Beaver 63 | - Night Owl 64 | - 3MS 65 | - 3D Chameleon 66 | - Quattro Box 67 | - PicoMMU 68 | - MMX 69 | - Custom... 70 | - Synchronized movement of extruder and gear motors (with sync feedback control) to overcome friction and even work with FLEX materials! 71 | - Support for all type of sensor: pre-gate, post-gear, combiner gate sensors, extruder entry sensors, toolhead sensors 72 | - Full Spoolman integration 73 | - Multiple MMUs managed as one 74 | - Support for motorized filament buffer systems for rewinding 75 | - Suite of startup macros that include sophisticated parking options for filament change or error operations 76 | - Implements a Tool-to-Gate mapping so that the physical spool can be mapped to any tool 77 | - EndlessSpool allowing a spool to automatically be mapped and take over from a spool that runs out 78 | - Sophisticated logging options (console and separate mmu.log file) 79 | - Can define material type and color in each gate for visualization and customized settings (like Pressure Advance) 80 | - Automated calibration for easy setup 81 | - Supports MMU "bypass" gate functionality 82 | - Moonraker update-manager support 83 | - Moonraker gcode pre-parsing to extract important print information 84 | - Complete persistence of state and statistics across restarts 85 | - Optional integrated encoder driver that validates filament movement, runout, clog detection and flow rate verification! 86 | - Vast customization options most of which can be changed and tested at runtime 87 | - Integrated help, testing and soak-testing procedures 88 | - Gcode pre-processor check that all the required tools are avaialble! 89 | - Drives LEDs for functional feed and some bling! 90 | - Built in tip forming and filament cutter support (both toolhead and at MMU) 91 | - Klipperscreen and Mainsail/Fluidd UI 92 | - Lots more... Detail change log can be found in the [Wiki](https://github.com/moggieuk/Happy-Hare/wiki/Change-Log) 93 | 94 | 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! 95 | 96 |

universal_mmu_driver.png

97 | 98 |

KlipperScreen-Happy Hare editionMailsail/Fluidd support

99 | 100 |
101 | 102 | ## ![#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 103 | 104 | 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: 105 | 106 | ``` 107 | cd ~ 108 | git clone https://github.com/moggieuk/Happy-Hare.git 109 | ``` 110 | 111 |
112 | 113 | ## ![#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 114 | 115 | 116 | 117 | 135 | 136 |
wiki 118 | MMU's are complexd! Fortunately Happy Hare has elaborate documentation logically organized in the Wiki 119 | 120 |


121 | 122 | **Other Resources:** 123 |

124 | 125 | Great (english) overview including Mainsail UI support 126 | 127 |
128 | Instructional video (german) created by Crydteam 129 | 130 |
131 | Happy Hare introduction (introduction) by Silverback 132 |
133 | 134 |
137 | 138 |
139 | 140 | ## ![#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? 141 | 142 | 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: 143 | 144 |

Example Prints

145 |

Example Prints

146 |
147 | 148 | ## ![#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: 149 | 150 | 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)!** 151 | 152 |

My Setup

153 |

154 | There once was a printer so keen,
155 | To print in red, yellow, and green.
156 | It whirred and it spun,
157 | Mixing colors for fun,
158 | The most vibrant prints ever seen!
159 |

160 | 161 | --- 162 | 163 | ```yml 164 | (\_/) 165 | ( *,*) 166 | (")_(") Happy Hare Ready 167 | ``` 168 | -------------------------------------------------------------------------------- /config/addons/README.md: -------------------------------------------------------------------------------- 1 | # Addons 2 | 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) 3 | 4 |
5 | 6 | > [!IMPORTANT] 7 | > 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. 8 | -------------------------------------------------------------------------------- /config/addons/blobifier_hw.cfg: -------------------------------------------------------------------------------- 1 | 2 | ########################################################################################## 3 | # The servo hardware configuration. Change the values to your needs. 4 | # 5 | [mmu_servo blobifier] 6 | # Pin for the servo. 7 | pin: PG14 8 | # Adjust this value until a 'BLOBIFIER_SERVO POS=out' extends the tray fully without a 9 | # buzzing sound 10 | minimum_pulse_width: 0.00053 11 | # Adjust this value until a 'BLOBIFIER_SERVO POS=in' retracts the tray fully without a 12 | # buzzing sound 13 | maximum_pulse_width: 0.0023 14 | # Leave this value at 180 15 | maximum_servo_angle: 180 16 | 17 | 18 | ########################################################################################## 19 | # The bucket hardware configuration. Change the pin to whatever pin you've connected the 20 | # switch to. 21 | # 22 | [gcode_button bucket] 23 | pin: ^PG15 # The pullup ( ^ ) is important here. 24 | press_gcode: 25 | M117 bucket installed 26 | release_gcode: 27 | M117 bucket removed 28 | _BLOBIFIER_COUNT_RESET 29 | -------------------------------------------------------------------------------- /config/addons/mmu_eject_buttons.cfg: -------------------------------------------------------------------------------- 1 | # Include servo hardware definition separately to allow for automatic upgrade 2 | [include mmu_eject_buttons_hw.cfg] 3 | 4 | ########################################################################### 5 | # Optional hardware MMU eject buttons (e.g. QuattroBox) 6 | # 7 | # This is the supplementary macro to support dedicated per-gate eject 8 | # buttons for easy unloading. It is complimentary to the built-in auto 9 | # preload of filament 10 | # 11 | # To configure: 12 | # 1. Add this to your printer.cfg: 13 | # 14 | # [include mmu/addons/mmu_eject_buttons.cfg] 15 | # 16 | 17 | ########################################################################### 18 | # Macro to simply call MMU_EJECT for the specified gate 19 | # 20 | # This logic is separated from actual button h/w setup to facilitate upgrades 21 | # and to allow addition of logic (perhaps validation or warning logic) 22 | # 23 | [gcode_macro _MMU_EJECT_BUTTON] 24 | description: Wrapper around ejecting filament via dedicated hardware buttons 25 | gcode: 26 | {% set gate = params.GATE|default(-1)|int %} 27 | {% set mmu = printer['mmu'] %} 28 | {% set current_gate = mmu.gate %} 29 | 30 | # TODO add validation and warning logic 31 | MMU_EJECT GATE={gate} 32 | -------------------------------------------------------------------------------- /config/addons/mmu_eject_buttons_hw.cfg: -------------------------------------------------------------------------------- 1 | 2 | ########################################################################################## 3 | # The eject button hardware configuration. Change the values to your needs and number 4 | # of gates 5 | # 6 | 7 | [gcode_button mmu_eject_button_0] 8 | pin: mmu:EJECT_BUTTON_0 9 | press_gcode: _MMU_EJECT_BUTTON GATE=0 10 | 11 | [gcode_button mmu_eject_button_1] 12 | pin: mmu:EJECT_BUTTON_1 13 | press_gcode: _MMU_EJECT_BUTTON GATE=1 14 | 15 | [gcode_button mmu_eject_button_2] 16 | pin: mmu:EJECT_BUTTON_2 17 | press_gcode: _MMU_EJECT_BUTTON GATE=2 18 | 19 | [gcode_button mmu_eject_button_3] 20 | pin: mmu:EJECT_BUTTON_3 21 | press_gcode: _MMU_EJECT_BUTTON GATE=3 22 | -------------------------------------------------------------------------------- /config/addons/mmu_erec_cutter.cfg: -------------------------------------------------------------------------------- 1 | # Include servo hardware definition separately to allow for automatic upgrade 2 | [include mmu_erec_cutter_hw.cfg] 3 | 4 | ########################################################################### 5 | # Optional EREC Filament Cutter Support 6 | # 7 | # https://github.com/kevinakasam/ERCF_Filament_Cutter 8 | # 9 | # This is the supplementary macro to support filament cutting at the MMU 10 | # on a ERCF design. 11 | # 12 | # To configure: 13 | # 1. Add this to your printer.cfg: 14 | # 15 | # [include mmu/addons/mmu_erec_cutter.cfg] 16 | # 17 | # 2. In mmu_macro_vars.cfg, change this line: 18 | # 19 | # variable_user_post_unload_extension : "EREC_CUTTER_ACTION" 20 | # 21 | # 3. Tune the servo configuration and macro "variables" below 22 | # 23 | 24 | # EREC CUTTER CONFIGURATION ----------------------------------------------- 25 | # (addons/mmu_erec_cutter.cfg) 26 | # 27 | [gcode_macro _EREC_VARS] 28 | description: Empty macro to store the variables 29 | gcode: # Leave empty 30 | 31 | # These variables control the servo movement 32 | variable_servo_closed_angle : 70 ; Servo angle for closed position with bowden aligned MMU 33 | variable_servo_open_angle : 10 ; Servo angle to open up the cutter and move bowden away from MMU 34 | variable_servo_duration : 1.5 ; Time (s) of PWM pulse train to activate servo 35 | variable_servo_idle_time : 1.8 ; Time (s) to let the servo to reach it's position 36 | 37 | # Controls for feed and cut lengths 38 | variable_feed_length : 48 ; Distance in mm from gate parking position to blade (ERCFv1.1: 58, v2/other: 48) 39 | variable_cut_length : 10 ; Amount in mm of filament to cut 40 | variable_cut_attempts : 1 ; Number of times the cutter tries to cut the filament 41 | 42 | 43 | ########################################################################### 44 | # Macro to perform the cutting step. Designed to be included to the 45 | # _MMU_POST_UNLOAD step 46 | # 47 | [gcode_macro EREC_CUTTER_ACTION] 48 | description: Cut off the filament tip at the MMU after the unload sequence is complete 49 | gcode: 50 | {% set vars = printer["gcode_macro _EREC_VARS"] %} 51 | 52 | MMU_LOG MSG="Cutting filament tip..." 53 | 54 | _CUTTER_OPEN 55 | _MMU_STEP_MOVE MOVE={vars.feed_length + vars.cut_length} 56 | {% for i in range(vars.cut_attempts - 1) %} 57 | _CUTTER_CLOSE 58 | _CUTTER_OPEN 59 | {% endfor %} 60 | _MMU_STEP_MOVE MOVE=-1 61 | _CUTTER_CLOSE 62 | _MMU_EVENT EVENT="filament_cut" # Count as one cut for consumption counter 63 | 64 | _MMU_STEP_SET_FILAMENT STATE=2 # FILAMENT_POS_START_BOWDEN 65 | _MMU_STEP_UNLOAD_GATE # Repeat gate parking move 66 | _MMU_M400 # Wait on both move queues 67 | 68 | [gcode_macro _CUTTER_ANGLE] 69 | description: Helper macro to set cutter servo angle 70 | gcode: 71 | {% set angle = params.ANGLE|default(0)|int %} 72 | SET_SERVO SERVO=cut_servo ANGLE={angle} 73 | 74 | [gcode_macro _CUTTER_CLOSE] 75 | description: Helper macro to set cutting servo the closed position 76 | gcode: 77 | {% set vars = printer["gcode_macro _EREC_VARS"] %} 78 | SET_SERVO SERVO=cut_servo ANGLE={vars.servo_closed_angle} DURATION={vars.servo_duration} 79 | G4 P{vars.servo_idle_time * 1000} 80 | RESPOND MSG="EREC Cutter closed" 81 | M400 82 | 83 | [gcode_macro _CUTTER_OPEN] 84 | description: Helper macro to set cutting servo the open position 85 | gcode: 86 | {% set vars = printer["gcode_macro _EREC_VARS"] %} 87 | SET_SERVO SERVO=cut_servo ANGLE={vars.servo_open_angle} DURATION={vars.servo_duration} 88 | G4 P{vars.servo_idle_time * 1000} 89 | RESPOND MSG="EREC Cutter open" 90 | M400 91 | 92 | -------------------------------------------------------------------------------- /config/addons/mmu_erec_cutter_hw.cfg: -------------------------------------------------------------------------------- 1 | 2 | ########################################################################################## 3 | # The servo hardware configuration. Change the values to your needs. 4 | # 5 | [mmu_servo cut_servo] 6 | pin: mmu:PA7 # Extra Pin on the ERCF easy Board 7 | maximum_servo_angle: 180 # Set this to 60 for a 60° Servo 8 | minimum_pulse_width: 0.0005 # Adapt these for your servo 9 | maximum_pulse_width: 0.0025 # Adapt these for your servo 10 | 11 | -------------------------------------------------------------------------------- /config/base/mmu.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # 4 | # EDIT THIS FILE BASED ON YOUR SETUP 5 | # 6 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 7 | # moggieuk@hotmail.com 8 | # This file may be distributed under the terms of the GNU GPLv3 license. 9 | # 10 | # Goal: Happy Hare MMU hardware pin config 11 | # 12 | # (\_/) 13 | # ( *,*) 14 | # (")_(") Happy Hare Ready 15 | # 16 | # 17 | # This contains aliases for pins for MCU type {brd_type} 18 | # 19 | [mcu mmu] 20 | serial: {serial} # Change to `canbus_uuid: 1234567890` for CANbus setups 21 | 22 | 23 | # PIN ALIASES FOR MMU MCU BOARD ---------------------------------------------------------------------------------------- 24 | # ██████╗ ██╗███╗ ██╗ █████╗ ██╗ ██╗ █████╗ ███████╗ 25 | # ██╔══██╗██║████╗ ██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝ 26 | # ██████╔╝██║██╔██╗ ██║ ███████║██║ ██║███████║███████╗ 27 | # ██╔═══╝ ██║██║╚██╗██║ ██╔══██║██║ ██║██╔══██║╚════██║ 28 | # ██║ ██║██║ ╚████║ ██║ ██║███████╗██║██║ ██║███████║ 29 | # ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═╝╚══════╝ 30 | # Section to create alias for pins used by MMU for easier integration into Klippain and RatOS. The names match those 31 | # referenced in the mmu_hardware.cfg file. If you get into difficulty you can also comment out this aliases definition 32 | # completely and configure the pin names directly into mmu_hardware.cfg. However, use of aliases is encouraged. 33 | 34 | # Note: that aliases are not created for TOOLHEAD_SENSOR, EXTRUDER_SENSOR or SYNC_FEEDBACK_SENSORS because those are 35 | # most likely on the printer's main mcu. These should be set directly in mmu_hardware.cfg 36 | # 37 | [board_pins mmu] 38 | mcu: mmu # Assumes using an external / extra mcu dedicated to MMU 39 | aliases: 40 | MMU_GEAR_UART={gear_uart_pin}, 41 | MMU_GEAR_STEP={gear_step_pin}, 42 | MMU_GEAR_DIR={gear_dir_pin}, 43 | MMU_GEAR_ENABLE={gear_enable_pin}, 44 | MMU_GEAR_DIAG={gear_diag_pin}, 45 | 46 | MMU_GEAR_UART_1={gear_1_uart_pin}, 47 | MMU_GEAR_STEP_1={gear_1_step_pin}, 48 | MMU_GEAR_DIR_1={gear_1_dir_pin}, 49 | MMU_GEAR_ENABLE_1={gear_1_enable_pin}, 50 | MMU_GEAR_DIAG_1={gear_1_diag_pin}, 51 | 52 | MMU_GEAR_UART_2={gear_2_uart_pin}, 53 | MMU_GEAR_STEP_2={gear_2_step_pin}, 54 | MMU_GEAR_DIR_2={gear_2_dir_pin}, 55 | MMU_GEAR_ENABLE_2={gear_2_enable_pin}, 56 | MMU_GEAR_DIAG_2={gear_2_diag_pin}, 57 | 58 | MMU_GEAR_UART_3={gear_3_uart_pin}, 59 | MMU_GEAR_STEP_3={gear_3_step_pin}, 60 | MMU_GEAR_DIR_3={gear_3_dir_pin}, 61 | MMU_GEAR_ENABLE_3={gear_3_enable_pin}, 62 | MMU_GEAR_DIAG_3={gear_3_diag_pin}, 63 | 64 | MMU_SEL_UART={selector_uart_pin}, 65 | MMU_SEL_STEP={selector_step_pin}, 66 | MMU_SEL_DIR={selector_dir_pin}, 67 | MMU_SEL_ENABLE={selector_enable_pin}, 68 | MMU_SEL_DIAG={selector_diag_pin}, 69 | MMU_SEL_ENDSTOP={selector_endstop_pin}, 70 | MMU_SEL_SERVO={selector_servo_pin}, 71 | 72 | MMU_ENCODER={encoder_pin}, 73 | MMU_GATE_SENSOR={gate_sensor_pin}, 74 | MMU_NEOPIXEL={neopixel_pin}, 75 | 76 | MMU_PRE_GATE_0={pre_gate_0_pin}, 77 | MMU_PRE_GATE_1={pre_gate_1_pin}, 78 | MMU_PRE_GATE_2={pre_gate_2_pin}, 79 | MMU_PRE_GATE_3={pre_gate_3_pin}, 80 | MMU_PRE_GATE_4={pre_gate_4_pin}, 81 | MMU_PRE_GATE_5={pre_gate_5_pin}, 82 | MMU_PRE_GATE_6={pre_gate_6_pin}, 83 | MMU_PRE_GATE_7={pre_gate_7_pin}, 84 | MMU_PRE_GATE_8={pre_gate_8_pin}, 85 | MMU_PRE_GATE_9={pre_gate_9_pin}, 86 | MMU_PRE_GATE_10={pre_gate_10_pin}, 87 | MMU_PRE_GATE_11={pre_gate_11_pin}, 88 | 89 | MMU_POST_GEAR_0={gear_sensor_0_pin}, 90 | MMU_POST_GEAR_1={gear_sensor_1_pin}, 91 | MMU_POST_GEAR_2={gear_sensor_2_pin}, 92 | MMU_POST_GEAR_3={gear_sensor_3_pin}, 93 | MMU_POST_GEAR_4={gear_sensor_4_pin}, 94 | MMU_POST_GEAR_5={gear_sensor_5_pin}, 95 | MMU_POST_GEAR_6={gear_sensor_6_pin}, 96 | MMU_POST_GEAR_7={gear_sensor_7_pin}, 97 | MMU_POST_GEAR_8={gear_sensor_8_pin}, 98 | MMU_POST_GEAR_9={gear_sensor_9_pin}, 99 | MMU_POST_GEAR_10={gear_sensor_10_pin}, 100 | MMU_POST_GEAR_11={gear_sensor_11_pin}, 101 | 102 | MMU_ESPOOLER_RWD_0={espooler_rwd_0_pin}, 103 | MMU_ESPOOLER_FWD_0={espooler_fwd_0_pin}, 104 | MMU_ESPOOLER_EN_0={espooler_en_0_pin}, 105 | MMU_ESPOOLER_TRIG_0=, 106 | MMU_ESPOOLER_RWD_1={espooler_rwd_1_pin}, 107 | MMU_ESPOOLER_FWD_1={espooler_fwd_1_pin}, 108 | MMU_ESPOOLER_EN_1={espooler_en_1_pin}, 109 | MMU_ESPOOLER_TRIG_1=, 110 | MMU_ESPOOLER_RWD_2={espooler_rwd_2_pin}, 111 | MMU_ESPOOLER_FWD_2={espooler_fwd_2_pin}, 112 | MMU_ESPOOLER_EN_2={espooler_en_2_pin}, 113 | MMU_ESPOOLER_TRIG_2=, 114 | MMU_ESPOOLER_RWD_3={espooler_rwd_3_pin}, 115 | MMU_ESPOOLER_FWD_3={espooler_fwd_3_pin}, 116 | MMU_ESPOOLER_EN_3={espooler_en_3_pin}, 117 | MMU_ESPOOLER_TRIG_3=, 118 | 119 | -------------------------------------------------------------------------------- /config/base/mmu_cut_tip.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # Supporting macros 4 | # 5 | # THIS FILE IS READ ONLY 6 | # 7 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 8 | # moggieuk@hotmail.com 9 | # This file may be distributed under the terms of the GNU GPLv3 license. 10 | # 11 | # Goal: Standalone Tip Cutting for "Filametrix" style toolhead cutters 12 | # 13 | # (\_/) 14 | # ( *,*) 15 | # (")_(") Happy Hare Ready 16 | # 17 | # 18 | # When using this macro it is important to turn off tip forming in your slicer 19 | # (read the wiki: Slicer Setup & Toolchange-Movement pages) 20 | # Then set the following parameters in mmu_parameters.cfg: 21 | # 22 | # form_tip_macro: _MMU_CUT_TIP 23 | # force_form_tip_standalone: 1 24 | # 25 | # This will ensure this macro is always called either in out of a print 26 | # 27 | # NOTE: 28 | # The park position of the filament is relative to the nozzle and 29 | # represents where the end of the filament is after cutting. The park position 30 | # is important and used by Happy Hare both to finish unloading the extruder 31 | # as well as to calculate how far to advance the filament on the subsequent load. 32 | # It is set dynamically in gcode with this construct: 33 | # SET_GCODE_VARIABLE MACRO=_MMU_CUT_TIP VARIABLE=output_park_pos VALUE=.. 34 | # 35 | [gcode_macro _MMU_CUT_TIP] 36 | description: Cut filament by pressing the cutter on a pin with a horizontal movement 37 | 38 | # -------------------------- Internal Don't Touch ------------------------- 39 | variable_output_park_pos: 0 # Dynamically set in this macro 40 | 41 | gcode: 42 | {% set final_eject = params.FINAL_EJECT|default(0)|int %} 43 | {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} 44 | {% set park_vars = printer['gcode_macro _MMU_PARK'] %} 45 | {% set pin_loc_x, pin_loc_y = vars.pin_loc_xy|map('float') %} 46 | {% set pin_park_dist = vars['pin_park_dist']|float %} 47 | {% set retract_length = vars['retract_length']|float %} 48 | {% set simple_tip_forming = vars['simple_tip_forming']|default(true)|lower == 'true' %} 49 | {% set blade_pos = vars['blade_pos']|float %} 50 | {% set rip_length = vars['rip_length']|float %} 51 | {% set pushback_length = vars['pushback_length']|float %} 52 | {% set pushback_dwell_time = vars['pushback_dwell_time']|int %} 53 | {% set extruder_move_speed = vars['extruder_move_speed']|float %} 54 | {% set travel_speed = vars['travel_speed']|float %} 55 | {% set restore_position = vars['restore_position']|default(true)|lower == 'true' %} 56 | {% set extruder_park_pos = blade_pos + rip_length %} 57 | {% set cutting_axis = vars['cutting_axis'] %} 58 | 59 | {% if cutting_axis == "x" %} 60 | {% set pin_park_x_loc = pin_loc_x + pin_park_dist %} 61 | {% set pin_park_y_loc = pin_loc_y %} 62 | {% else %} 63 | {% set pin_park_y_loc = pin_loc_y + pin_park_dist %} 64 | {% set pin_park_x_loc = pin_loc_x %} 65 | {% endif %} 66 | 67 | {% if "xy" not in printer.toolhead.homed_axes %} 68 | MMU_LOG MSG="Automatically homing XY" 69 | G28 X Y 70 | _CUT_TIP_MOVE_IN_BOUNDS 71 | {% endif %} 72 | 73 | SAVE_GCODE_STATE NAME=_MMU_CUT_TIP_state # Save after possible homing operation to prevent 0,0 being recorded 74 | 75 | G90 # Absolute positioning 76 | M83 # Relative extrusion 77 | G92 E0 78 | 79 | # Step 1 - Calculate initial retract to save filament waste, repeat to allow some cooling 80 | {% set effective_retract_length = retract_length - printer.mmu.extruder_filament_remaining - park_vars.retracted_length %} 81 | {% if effective_retract_length > 0 %} 82 | MMU_LOG MSG="Retracting filament {effective_retract_length|round(1)}mm prior to cut" 83 | G1 E-{effective_retract_length} F{extruder_move_speed * 60} 84 | {% if simple_tip_forming %} 85 | G1 E{effective_retract_length / 2} F{extruder_move_speed * 60} 86 | G1 E-{effective_retract_length / 2} F{extruder_move_speed * 60} 87 | {% endif %} 88 | {% endif %} 89 | 90 | # Step 2 - Perform the cut 91 | _CUT_TIP_ADJUST_CURRENT 92 | _CUT_TIP_MOVE_TO_CUTTER_PIN PIN_PARK_X_LOC={pin_park_x_loc} PIN_PARK_Y_LOC={pin_park_y_loc} 93 | _CUT_TIP_GANTRY_SERVO_DOWN 94 | _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} 95 | _CUT_TIP_GANTRY_SERVO_UP 96 | _CUT_TIP_RESTORE_CURRENT 97 | _MMU_EVENT EVENT="filament_cut" 98 | 99 | # Step 3 - Pushback of the tip residual into the hotend to avoid future catching (ideally past the PTFE/metal boundary) 100 | {% set effective_pushback_length = [pushback_length, retract_length - printer.mmu.extruder_filament_remaining - park_vars.retracted_length]|min %} 101 | {% if effective_pushback_length > 0 %} 102 | MMU_LOG MSG="Pushing filament fragment back {effective_pushback_length|round(1)}mm after cut" 103 | G1 E{effective_pushback_length} F{extruder_move_speed * 60} 104 | G4 P{pushback_dwell_time} 105 | G1 E-{effective_pushback_length} F{extruder_move_speed * 60} 106 | {% endif %} 107 | 108 | # Final eject is for testing 109 | {% if final_eject %} 110 | G92 E0 111 | G1 E-80 F{extruder_move_speed * 60} 112 | {% endif %} 113 | 114 | # Dynamically set the required output variables for Happy Hare 115 | SET_GCODE_VARIABLE MACRO=_MMU_CUT_TIP VARIABLE=output_park_pos VALUE={extruder_park_pos} 116 | 117 | # Restore state and optionally position (usually on wipetower) 118 | RESTORE_GCODE_STATE NAME=_MMU_CUT_TIP_state MOVE={1 if restore_position else 0} MOVE_SPEED={travel_speed} 119 | 120 | ########################################################################### 121 | # Helper macro to alter X/Y stepper current during cut operation 122 | # 123 | [gcode_macro _CUT_TIP_ADJUST_CURRENT] 124 | description: Helper to optionally increase X/Y stepper current prior to cut 125 | variable_current_map: {} # Internal, don't set 126 | gcode: 127 | {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} 128 | {% set cut_axis_steppers = (vars['cut_axis_steppers'] | default('')).split(',') | map('trim') | list %} 129 | {% set cut_stepper_current = vars['cut_stepper_current'] | default(100) | int %} 130 | {% set tmc_types = ["tmc2209", "tmc2130", "tmc2208", "tmc2660", "tmc5160", "tmc2240"] %} 131 | 132 | {% if not current_map %} 133 | {% set ns = namespace(run_current={}) %} 134 | {% if cut_stepper_current != 100 %} 135 | {% for stepper in cut_axis_steppers %} 136 | {% for tmc in tmc_types %} 137 | {% set fullname = tmc ~ ' ' ~ stepper %} 138 | {% if printer[fullname] is defined %} 139 | # Save original run_current and reset to new value 140 | {% set cur_rc = printer[fullname].run_current %} 141 | {% if ns.run_current.update({stepper: cur_rc}) %}{% endif %} 142 | {% set new_rc = cur_rc * cut_stepper_current | float / 100 %} 143 | MMU_LOG MSG="Adjusting {stepper} current" 144 | SET_TMC_CURRENT STEPPER={stepper} CURRENT={new_rc} 145 | {% endif %} 146 | {% endfor %} 147 | {% endfor %} 148 | {% endif %} 149 | 150 | SET_GCODE_VARIABLE MACRO=_CUT_TIP_ADJUST_CURRENT VARIABLE=current_map VALUE="{ns.run_current}" 151 | {% endif %} 152 | 153 | ########################################################################### 154 | # Helper macro to restore X/Y stepper current after cutting action 155 | # 156 | [gcode_macro _CUT_TIP_RESTORE_CURRENT] 157 | description: Helper to restore X/Y stepper current after cutting action 158 | gcode: 159 | {% set current_vars = printer['gcode_macro _CUT_TIP_ADJUST_CURRENT'] %} 160 | {% set current_map = current_vars['current_map'] %} 161 | 162 | {% if current_map %} 163 | {% for stepper, current in current_map.items() %} 164 | MMU_LOG MSG="Restoring {stepper} current" 165 | SET_TMC_CURRENT STEPPER={stepper} CURRENT={current} 166 | {% endfor %} 167 | {% endif %} 168 | 169 | SET_GCODE_VARIABLE MACRO=_CUT_TIP_ADJUST_CURRENT VARIABLE=current_map VALUE="{{}}" 170 | 171 | 172 | ########################################################################### 173 | # Helper macro to ensure toolhead is in bounds after home in case it is 174 | # used as a restore position point 175 | # 176 | [gcode_macro _CUT_TIP_MOVE_IN_BOUNDS] 177 | description: Helper to move the toolhead to a legal position after homing 178 | gcode: 179 | {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} 180 | {% set travel_speed = vars['travel_speed']|float %} 181 | 182 | {% set pos = printer.gcode_move.gcode_position %} 183 | {% set axis_minimum = printer.toolhead.axis_minimum %} 184 | {% set axis_maximum = printer.toolhead.axis_maximum %} 185 | {% set x = [axis_minimum.x, [axis_maximum.x, pos.x]|min]|max %} 186 | {% set y = [axis_minimum.y, [axis_maximum.y, pos.y]|min]|max %} 187 | 188 | 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 189 | G1 X{x} Y{y} F{travel_speed * 60} 190 | 191 | 192 | ########################################################################### 193 | # Helper macro for tip cutting 194 | # 195 | [gcode_macro _CUT_TIP_MOVE_TO_CUTTER_PIN] 196 | description: Helper to move the toolhead to the target pin in either safe or faster way, depending on toolhead clearance 197 | gcode: 198 | {% set pin_park_x_loc = params.PIN_PARK_X_LOC|float %} 199 | {% set pin_park_y_loc = params.PIN_PARK_Y_LOC|float %} 200 | {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} 201 | 202 | {% set safe_margin_x, safe_margin_y = vars.safe_margin_xy|map('float') %} 203 | {% set travel_speed = vars['travel_speed']|float %} 204 | {% set cutting_axis = vars['cutting_axis'] %} 205 | 206 | {% 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) %} 207 | # Make a safe but slower travel move 208 | {% if cutting_axis == "x" %} 209 | G1 X{pin_park_x_loc} F{travel_speed * 60} 210 | G1 Y{pin_park_y_loc} F{travel_speed * 60} 211 | {% else %} 212 | G1 Y{pin_park_y_loc} F{travel_speed * 60} 213 | G1 X{pin_park_x_loc} F{travel_speed * 60} 214 | {% endif %} 215 | {% else %} 216 | G1 X{pin_park_x_loc} Y{pin_park_y_loc} F{travel_speed * 60} 217 | {% endif %} 218 | 219 | 220 | ########################################################################### 221 | # Helper macro for tip cutting 222 | # 223 | [gcode_macro _CUT_TIP_DO_CUT_MOTION] 224 | description: Helper to do a single cut movement 225 | gcode: 226 | {% set pin_park_x_loc = params.PIN_PARK_X_LOC | float %} 227 | {% set pin_park_y_loc = params.PIN_PARK_Y_LOC | float %} 228 | {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} 229 | {% set cutting_axis = vars['cutting_axis'] %} 230 | 231 | {% set pin_loc = vars['pin_loc_compressed']|default(-999)|float %} 232 | {% if pin_loc != -999 %} 233 | # Old one-dimensional pin_loc_compressed config 234 | {% if cutting_axis == "x" %} 235 | {% set pin_loc_compressed_x = pin_loc %} 236 | {% set pin_loc_compressed_y = pin_park_y_loc %} 237 | {% else %} 238 | {% set pin_loc_compressed_x = pin_park_x_loc %} 239 | {% set pin_loc_compressed_y = pin_loc %} 240 | {% endif %} 241 | {% else %} 242 | # New config 243 | {% set pin_loc_compressed_x, pin_loc_compressed_y = vars.pin_loc_compressed_xy|map('float') %} 244 | {% endif %} 245 | 246 | {% set cut_fast_move_fraction = vars['cut_fast_move_fraction']|float %} 247 | {% set cut_fast_move_speed = vars['cut_fast_move_speed']|float %} 248 | {% set cut_slow_move_speed = vars['cut_slow_move_speed']|float %} 249 | {% set cut_dwell_time = vars['cut_dwell_time']|float %} 250 | {% set evacuate_speed = vars['evacuate_speed']|float %} 251 | {% set rip_length = vars['rip_length']|float %} 252 | {% set rip_speed = vars['rip_speed']|float %} 253 | 254 | 255 | {% set fast_slow_transition_loc_x = (pin_loc_compressed_x - pin_park_x_loc) * cut_fast_move_fraction + pin_park_x_loc|float %} 256 | {% set fast_slow_transition_loc_y = (pin_loc_compressed_y - pin_park_y_loc) * cut_fast_move_fraction + pin_park_y_loc|float %} 257 | 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 258 | G1 X{pin_loc_compressed_x} Y{pin_loc_compressed_y} F{cut_slow_move_speed * 60} # Do the cut in slow move 259 | 260 | G4 P{cut_dwell_time} 261 | {% if rip_length > 0 %} 262 | G1 E-{rip_length} F{rip_speed * 60} 263 | {% endif %} 264 | 265 | G1 X{pin_park_x_loc} Y{pin_park_y_loc} F{evacuate_speed * 60} # Evacuate 266 | 267 | 268 | ########################################################################### 269 | # Helper macro for tip cutting 270 | # 271 | [gcode_macro _CUT_TIP_GANTRY_SERVO_DOWN] 272 | description: Operate optional gantry servo operated pin 273 | gcode: 274 | {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} 275 | {% set gantry_servo_enabled = vars['gantry_servo_enabled']|default(true)|lower == 'true' %} 276 | {% set angle = vars['gantry_servo_down_angle']|float %} 277 | 278 | {% if gantry_servo_enabled %} 279 | SET_SERVO SERVO=mmu_gantry_servo ANGLE={angle} 280 | G4 P500 # Pause to ensure servo is fully down before movement 281 | {% endif %} 282 | 283 | 284 | ########################################################################### 285 | # Helper macro for tip cutting 286 | # 287 | [gcode_macro _CUT_TIP_GANTRY_SERVO_UP] 288 | description: Operate optional gantry servo operated pin 289 | gcode: 290 | {% set vars = printer['gcode_macro _MMU_CUT_TIP_VARS'] %} 291 | {% set gantry_servo_enabled = vars['gantry_servo_enabled']|default(true)|lower == 'true' %} 292 | {% set angle = vars['gantry_servo_up_angle']|float %} 293 | 294 | {% if gantry_servo_enabled %} 295 | SET_SERVO SERVO=mmu_gantry_servo ANGLE={angle} DURATION=0.5 296 | {% endif %} 297 | -------------------------------------------------------------------------------- /config/base/mmu_form_tip.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # Supporting macros 4 | # 5 | # THIS FILE IS READ ONLY 6 | # 7 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 8 | # moggieuk@hotmail.com 9 | # This file may be distributed under the terms of the GNU GPLv3 license. 10 | # 11 | # Goal: Standalone Tip Forming roughly based on Superslicer 12 | # 13 | # (\_/) 14 | # ( *,*) 15 | # (")_(") Happy Hare Ready 16 | # 17 | # 18 | # To configure, set 19 | # 'form_tip_macro: _MMU_FORM_TIP' in 'mmu_parameters.cfg' 20 | # 21 | # This macro is, by default, called by Happy Hare to form filament tip 22 | # prior to unloading. This will need to be tuned for your particular 23 | # setup. Although the slicer can also perform similarly you must also 24 | # tune tips here. The slicer will be used when printing, this logic will be 25 | # used when not in print. Because of the need to setup twice, it is recommended 26 | # that you turn off slicer tip forming and to use this routine in all circumstances. 27 | # 28 | # To force Happy Hare to always run this when loading filament add: 29 | # 'force_form_tip_standalone: 1' in 'mmu_parameters.cfg' 30 | # 31 | # Also decide on whether you want toolhead to remain over wipetower while tool 32 | # changing or move to park location (see 'enable_park' in mmu_sequence.cfg) 33 | # 34 | [gcode_macro _MMU_FORM_TIP] 35 | description: Standalone macro that mimics Superslicer process 36 | 37 | gcode: 38 | {% set final_eject = params.FINAL_EJECT|default(0)|int %} 39 | {% set vars = printer['gcode_macro _MMU_FORM_TIP_VARS'] %} 40 | {% set park_vars = printer['gcode_macro _MMU_PARK'] %} 41 | {% set unloading_speed_start = vars['unloading_speed_start']|int %} 42 | {% set unloading_speed = vars['unloading_speed']|int %} 43 | {% set ramming_volume = vars['ramming_volume']|float %} 44 | {% set ramming_volume_standalone = vars['ramming_volume_standalone']|float %} 45 | {% set cooling_tube_length = vars['cooling_tube_length']|float %} 46 | {% set cooling_tube_position = vars['cooling_tube_position']|float %} 47 | {% set initial_cooling_speed = vars['initial_cooling_speed']|int %} 48 | {% set final_cooling_speed = vars['final_cooling_speed']|int %} 49 | {% set cooling_moves = vars['cooling_moves']|int %} 50 | {% set toolchange_temp = vars['toolchange_temp']|default(0)|int %} 51 | {% set use_skinnydip = vars['use_skinnydip']|default(false)|lower == 'true' %} 52 | {% set use_fast_skinnydip = vars['use_fast_skinnydip']|default(false)|lower == 'true' %} 53 | {% set skinnydip_distance = vars['skinnydip_distance']|float %} 54 | {% set dip_insertion_speed = vars['dip_insertion_speed']|int %} 55 | {% set dip_extraction_speed = vars['dip_extraction_speed']|int %} 56 | {% set melt_zone_pause = vars['melt_zone_pause']|default(0)|int %} 57 | {% set cooling_zone_pause = vars['cooling_zone_pause']|default(0)|int %} 58 | {% set extruder_eject_speed = vars['extruder_eject_speed']|int %} 59 | {% set parking_distance = vars['parking_distance']|default(0)|float %} 60 | {% set orig_temp = printer.extruder.target %} 61 | {% set next_temp = params.NEXT_TEMP|default(orig_temp)|int %} 62 | 63 | # Useful state for customizing operations depending on mode 64 | {% set runout = printer.mmu.runout %} 65 | {% set printing = printer.mmu.print_state == 'printing' %} 66 | 67 | SAVE_GCODE_STATE NAME=MMU_FORM_TIP_state 68 | 69 | G91 # Relative positioning 70 | M83 # Relative extrusion 71 | G92 E0 72 | 73 | # Step 1 - Ramming 74 | # This is very generic and unlike slicer does not incorporate moves on the wipetower 75 | {% set ramming_volume = ramming_volume_standalone if not printing else ramming_volume %} 76 | {% if ramming_volume > 0 %} # Standalone Ramming 77 | {% set ratio = ramming_volume / 23.0 %} 78 | G1 E{0.5784 * ratio} F299 #7 79 | G1 E{0.5834 * ratio} F302 #3 80 | G1 E{0.5918 * ratio} F306 #6 81 | G1 E{0.6169 * ratio} F319 #6 82 | G1 E{0.3393 * ratio} F350 #0 83 | G1 E{0.3363 * ratio} F350 #0 84 | G1 E{0.7577 * ratio} F392 #6 85 | G1 E{0.8382 * ratio} F434 #3 86 | G1 E{0.7776 * ratio} F469 #9 87 | G1 E{0.1293 * ratio} F469 #9 88 | G1 E{0.9673 * ratio} F501 #2 89 | G1 E{1.0176 * ratio} F527 #2 90 | G1 E{0.5956 * ratio} F544 #6 91 | G1 E{0.4555 * ratio} F544 #6 92 | G1 E{1.0662 * ratio} F552 #4 93 | {% endif %} 94 | 95 | # Step 2 - Retraction / Nozzle Separation 96 | # This is where the tip spear shape comes from. Faster=longer/pointer/higher stringing 97 | {% set total_retraction_distance = cooling_tube_position - printer.mmu.extruder_filament_remaining - park_vars.retracted_length + cooling_tube_length - 15 %} 98 | G1 E-15 F{1.0 * unloading_speed_start * 60} # Fixed default value from SS 99 | {% if total_retraction_distance > 0 %} 100 | G1 E-{(0.7 * total_retraction_distance)|round(2)} F{1.0 * unloading_speed * 60} 101 | G1 E-{(0.2 * total_retraction_distance)|round(2)} F{0.5 * unloading_speed * 60} 102 | G1 E-{(0.1 * total_retraction_distance)|round(2)} F{0.3 * unloading_speed * 60} 103 | {% endif %} 104 | 105 | # Set toolchange temperature just prior to cooling moves (not fast skinnydip mode) 106 | {% if toolchange_temp > 0 %} 107 | M104 S{toolchange_temp} 108 | {% if not use_fast_skinnydip %} 109 | _WAIT_FOR_TEMP 110 | {% endif %} 111 | {% endif %} 112 | 113 | # Step 3 - Cooling Moves 114 | # Solidifies tip shape and helps break strings if formed 115 | {% set speed_inc = (final_cooling_speed - initial_cooling_speed) / (2 * cooling_moves - 1) %} 116 | {% for move in range(cooling_moves) %} 117 | {% set speed = initial_cooling_speed + speed_inc * move * 2 %} 118 | G1 E{cooling_tube_length} F{speed * 60} 119 | G1 E-{cooling_tube_length} F{(speed + speed_inc) * 60} 120 | {% endfor %} 121 | 122 | # Wait for extruder to reach toolchange temperature after cooling moves complete (fast skinnydip only) 123 | {% if toolchange_temp > 0 and use_skinnydip and use_fast_skinnydip %} 124 | _WAIT_FOR_TEMP 125 | {% endif %} 126 | 127 | # Step 4 - Skinnydip 128 | # Burns off very fine hairs (Good for PLA) 129 | {% if use_skinnydip %} 130 | G1 E{skinnydip_distance} F{dip_insertion_speed * 60} 131 | G4 P{melt_zone_pause} 132 | G1 E-{skinnydip_distance} F{dip_extraction_speed * 60} 133 | G4 P{cooling_zone_pause} 134 | {% endif %} 135 | 136 | # Set temperature target to next filament temp or starting temp. Note that we don't 137 | # wait because the temp will settle during the rest of the toolchange 138 | M104 S{next_temp} 139 | 140 | # Step 5 - Parking 141 | # Optional park filament at fixed location or eject completely (testing) 142 | {% if final_eject %} 143 | G92 E0 144 | G1 E-80 F{extruder_eject_speed * 60} 145 | {% elif parking_distance > 0 %} 146 | G90 # Absolute positioning 147 | M82 # Absolute extrusion 148 | G1 E-{parking_distance} F{extruder_eject_speed * 60} 149 | {% endif %} 150 | 151 | # Restore state 152 | RESTORE_GCODE_STATE NAME=MMU_FORM_TIP_state 153 | 154 | 155 | [gcode_macro _WAIT_FOR_TEMP] 156 | description: Helper function for fan assisted extruder temp reduction 157 | gcode: 158 | {% set vars = printer['gcode_macro _MMU_FORM_TIP_VARS'] %} 159 | {% set toolchange_temp = vars['toolchange_temp']|default(0)|int %} 160 | {% set toolchange_use_fan = vars['toolchange_fan_assist']|default(false)|lower == 'true' %} 161 | {% set toolchange_fan_speed = vars['toolchange_fan_speed']|default(50)|int %} 162 | {% set toolchange_fan = vars['toolchange_fan_name']|default('')|string %} 163 | 164 | MMU_LOG MSG='{"Waiting for extruder temp %d\u00B0C..." % toolchange_temp}' 165 | {% if toolchange_use_fan %} 166 | {% if printer.fan is defined or printer[toolchange_fan] is defined %} 167 | {% set orig_fan_speed = printer[toolchange_fan].speed if printer[toolchange_fan] is defined else printer.fan.speed %} 168 | M106 S{(toolchange_fan_speed / 100 * 255)|int} 169 | M109 S{toolchange_temp} 170 | M106 S{(orig_fan_speed * 255)|int} 171 | {% else %} 172 | MMU_LOG MSG="Warning: Printer part fan is not defined. Ignoring 'toolchange_use_fan' option" ERROR=1 173 | M109 S{toolchange_temp} 174 | {% endif %} 175 | {% else %} 176 | M109 S{toolchange_temp} 177 | {% endif %} 178 | 179 | -------------------------------------------------------------------------------- /config/base/mmu_hardware.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # 4 | # EDIT THIS FILE BASED ON YOUR SETUP 5 | # 6 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 7 | # moggieuk@hotmail.com 8 | # This file may be distributed under the terms of the GNU GPLv3 license. 9 | # 10 | # Goal: Happy Hare MMU hardware config file with config for {brd_type} MCU board 11 | # 12 | # (\_/) 13 | # ( *,*) 14 | # (")_(") Happy Hare Ready 15 | # 16 | # 17 | # Notes about setup of common external MCUs can be found here: 18 | # https://github.com/moggieuk/Happy-Hare/blob/main/doc/mcu_notes.md 19 | # 20 | # Note about "touch" endstops: Happy Hare provides extremely flexible homing options using both single steppers or 21 | # synced steppers. The "touch" option leverages stallguard and thus requires the appropriate 'diag_pin' and stallguard 22 | # parameters set on the TMC driver section. If you have the diag_pin exposed, it is harmless to define this because 23 | # they will only be used when explicitly needed and configured. 24 | # 25 | # Touch option for each stepper provides these benefits / possibilities (experimental): 26 | # - on extruder stepper allows for the automatic detection of the nozzle! 27 | # - on selector stepper allows for the automatic detection of filament stuck in the gate and subsequent recovery 28 | # - on gear stepper allows for the automatic detection of the extruder entrance 29 | # 30 | # In summary, "touch" homing with your MMU is an advanced option that requires patience and careful tuning. Everything 31 | # works with regular endstops and there are workaround options for certain homing points (like extruder entry) in 32 | # the absence of any endstop. I'm really interested in creative setups. Ping me on Discord (moggieuk#6538) 33 | # 34 | # See 'mmu.cfg' for serial definition and pins aliases 35 | # 36 | # HOMING CAPABLE EXTRUDER (VERY ADVANCED) ----------------------------------------------------------------------------- 37 | # With Happy Hare installed even the extruder can be homed. You will find the usual 'endstop' parameters can be added 38 | # to your '[extruder]' section. Useless you have some clever load cell attached to your nozzle it only really makes 39 | # sense to configure stallguard style "touch" homing. To do this add lines similar to this to your existing 40 | # '[extruder]' definition in printer.cfg. 41 | # 42 | # [extruder] 43 | # endstop_pin: tmc2209_extruder:virtual_endstop 44 | # 45 | # Also be sure to add the appropriate stallguard config to the TMC section, e.g. 46 | # 47 | # [tmc2209 extruder] 48 | # diag_pin: E_DIAG # Set to MCU pin connected to TMC DIAG pin for extruder 49 | # driver_SGTHRS: 100 # 255 is most sensitive value, 0 is least sensitive 50 | # 51 | # Happy Hare will take care of the rest and add a 'mmu_ext_touch' endstop automatically 52 | # 53 | 54 | 55 | # MMU MACHINE / TYPE --------------------------------------------------------------------------------------------------- 56 | # ███╗ ███╗███╗ ███╗██╗ ██╗ ███╗ ███╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗███████╗ 57 | # ████╗ ████║████╗ ████║██║ ██║ ████╗ ████║██╔══██╗██╔════╝██║ ██║██║████╗ ██║██╔════╝ 58 | # ██╔████╔██║██╔████╔██║██║ ██║ ██╔████╔██║███████║██║ ███████║██║██╔██╗ ██║█████╗ 59 | # ██║╚██╔╝██║██║╚██╔╝██║██║ ██║ ██║╚██╔╝██║██╔══██║██║ ██╔══██║██║██║╚██╗██║██╔══╝ 60 | # ██║ ╚═╝ ██║██║ ╚═╝ ██║╚██████╔╝ ██║ ╚═╝ ██║██║ ██║╚██████╗██║ ██║██║██║ ╚████║███████╗ 61 | # ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ 62 | [mmu_machine] 63 | 64 | # Number of selectable gate on (each) MMU. Generally this is a single number, but with multi-mmu (type-B) setups 65 | # it can be a comma separated list of the number of gates per unit. 66 | # E.g. 'num_gates: 4,4,2' for a 2xBox Turtle and 1xNight Owl multiplexed setup 67 | # 68 | num_gates: {num_gates} 69 | 70 | # MMU Vendor & Version is used to automatically configure some parameters and validate configuration 71 | # If custom set to "Other" and uncomment the additional parameters below 72 | # 73 | # ERCF 1.1 add "s" suffix for Springy, "b" for Binky, "t" for Triple-Decky 74 | # e.g. "1.1sb" for v1.1 with Springy mod and Binky encoder 75 | # ERCF 2.0 community edition ERCFv2 76 | # ERCF 2.5 77 | # Tradrack 1.0 add "e" if encoder is fitted (assumed to be Binky) 78 | # AngryBeaver 1.0 79 | # BoxTurtle 1.0 80 | # NightOwl 1.0 81 | # 3MS 1.0 82 | # 3D Chameleon 1.0 83 | # Pico 1.0 84 | # Prusa 3.0 NOT YET SUPPORTED - COMING SOON 85 | # Other Generic setup that may require further customization of 'cad' parameters. See doc in mmu_parameters.cfg 86 | # 87 | mmu_vendor: {mmu_vendor} # MMU family 88 | mmu_version: {mmu_version} # MMU hardware version number (add mod suffix documented above) 89 | 90 | # The following attributes are set internally from vendor/version above. Only uncomment to customize the vendor 91 | # default or for custom ("Other") designs 92 | # 93 | #selector_type: {selector_type} # E.g. LinearSelector (type-A), VirtualSelector (type-B), MacroSelector, RotarySelector, ... 94 | #variable_bowden_lengths: {variable_bowden_lengths} # 1 = If MMU design has different bowden lengths per gate, 0 = bowden length is the same 95 | #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) 96 | #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) 97 | #filament_always_gripped: {filament_always_gripped} # 1 = Filament is always trapped by MMU (most type-B designs), 0 = MMU can release filament 98 | #has_bypass: {has_bypass} # 1 = Bypass gate available, 0 = No filament bypass possible 99 | 100 | # Uncomment to change the display name in UI's. Defaults to the vendor name 101 | #display_name: My Precious 102 | 103 | homing_extruder: 1 # CAUTION: Normally this should be 1. 0 will disable the homing extruder capability 104 | 105 | 106 | # FILAMENT DRIVE GEAR STEPPER(S) -------------------------------------------------------------------------------------- 107 | # ██████╗ ███████╗ █████╗ ██████╗ 108 | # ██╔════╝ ██╔════╝██╔══██╗██╔══██╗ 109 | # ██║ ███╗█████╗ ███████║██████╔╝ 110 | # ██║ ██║██╔══╝ ██╔══██║██╔══██╗ 111 | # ╚██████╔╝███████╗██║ ██║██║ ██║ 112 | # ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ 113 | # Note that 'toolhead' & 'mmu_gear' endstops will automatically be added if a toolhead sensor or gate sensor is defined 114 | # 115 | # The default values are tested with the ERCF BOM NEMA14 motor. Please adapt these values to the motor you are using 116 | # Example : for NEMA17 motors, you'll usually use higher current 117 | # 118 | [tmc2209 stepper_mmu_gear] 119 | uart_pin: mmu:MMU_GEAR_UART 120 | uart_address: 0 # Only for old EASY-BRD mcu 121 | run_current: {gear_run_current} # ERCF BOM NEMA14 motor 122 | hold_current: {gear_hold_current} # Recommend to be small if not using "touch" or move (TMC stallguard) 123 | interpolate: True 124 | sense_resistor: 0.110 # Usually 0.11, 0.15 for BTT TMC2226 125 | stealthchop_threshold: 0 # Spreadcycle has more torque and better at speed 126 | # 127 | # Uncomment two lines below if you have TMC and want the ability to use filament "touch" homing with gear stepper 128 | #diag_pin: ^mmu:MMU_GEAR_DIAG # Set to MCU pin connected to TMC DIAG pin for gear stepper 129 | #driver_SGTHRS: 60 # 255 is most sensitive value, 0 is least sensitive 130 | 131 | [stepper_mmu_gear] 132 | step_pin: mmu:MMU_GEAR_STEP 133 | dir_pin: !mmu:MMU_GEAR_DIR 134 | enable_pin: !mmu:MMU_GEAR_ENABLE 135 | rotation_distance: 22.7316868 # Bondtech 5mm Drive Gears. Overridden by 'mmu_gear_rotation_distance' in mmu_vars.cfg 136 | gear_ratio: {gear_gear_ratio} # E.g. ERCF 80:20, Tradrack 50:17 137 | microsteps: 16 # Recommend 16. Increase only if you "step compress" issues when syncing 138 | full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree 139 | # 140 | # Uncomment the two lines below to enable filament "touch" homing option with gear motor 141 | #extra_endstop_pins: tmc2209_stepper_mmu_gear:virtual_endstop 142 | #extra_endstop_names: mmu_gear_touch 143 | 144 | # ADDITIONAL FILAMENT DRIVE GEAR STEPPERS FOR TYPE-B MMU's ------------------------------------------------------------- 145 | # Note that common parameters are inherited from base stepper_mmu_gear, but can be uniquely specified here too 146 | # 147 | # Filament Drive Gear_1 -------------------------- 148 | [tmc2209 stepper_mmu_gear_1] 149 | uart_pin: mmu:MMU_GEAR_UART_1 150 | 151 | [stepper_mmu_gear_1] 152 | step_pin: mmu:MMU_GEAR_STEP_1 153 | dir_pin: !mmu:MMU_GEAR_DIR_1 154 | enable_pin: !mmu:MMU_GEAR_ENABLE_1 155 | 156 | 157 | # SELECTOR STEPPER ---------------------------------------------------------------------------------------------------- 158 | # ███████╗███████╗██╗ ███████╗ ██████╗████████╗ ██████╗ ██████╗ 159 | # ██╔════╝██╔════╝██║ ██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ 160 | # ███████╗█████╗ ██║ █████╗ ██║ ██║ ██║ ██║██████╔╝ 161 | # ╚════██║██╔══╝ ██║ ██╔══╝ ██║ ██║ ██║ ██║██╔══██╗ 162 | # ███████║███████╗███████╗███████╗╚██████╗ ██║ ╚██████╔╝██║ ██║ 163 | # ╚══════╝╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ 164 | # Consult doc if you want to setup selector for "touch" homing instead or physical endstop 165 | # 166 | [tmc2209 stepper_mmu_selector] 167 | uart_pin: mmu:MMU_SEL_UART 168 | uart_address: 1 # Only for EASY-BRD 169 | run_current: {sel_run_current} # ERCF BOM NEMA17 motor 170 | hold_current: {sel_hold_current} # Can be small if not using "touch" movement (TMC stallguard) 171 | interpolate: True 172 | sense_resistor: 0.110 173 | stealthchop_threshold: 100 # Stallguard "touch" movement (slower speeds) best done with stealthchop 174 | # 175 | # Uncomment two lines below if you have TMC and want to use selector "touch" movement 176 | #diag_pin: ^mmu:MMU_SEL_DIAG # Set to MCU pin connected to TMC DIAG pin for selector stepper 177 | #driver_SGTHRS: 75 # 255 is most sensitive value, 0 is least sensitive 178 | 179 | [stepper_mmu_selector] 180 | step_pin: mmu:MMU_SEL_STEP 181 | dir_pin: !mmu:MMU_SEL_DIR 182 | enable_pin: !mmu:MMU_SEL_ENABLE 183 | rotation_distance: 40 184 | microsteps: 16 # Don't need high fidelity 185 | full_steps_per_rotation: 200 # 200 for 1.8 degree, 400 for 0.9 degree 186 | endstop_pin: ^mmu:MMU_SEL_ENDSTOP # Selector microswitch 187 | endstop_name: mmu_sel_home 188 | # Uncomment this line only if default endstop above is using stallguard 189 | #homing_retract_dist: 0 190 | # 191 | # Uncomment two lines below to give option of selector "touch" movement 192 | #extra_endstop_pins: tmc2209_stepper_mmu_selector:virtual_endstop 193 | #extra_endstop_names: mmu_sel_touch 194 | 195 | 196 | # SERVOS --------------------------------------------------------------------------------------------------------------- 197 | # ███████╗███████╗██████╗ ██╗ ██╗ ██████╗ ███████╗ 198 | # ██╔════╝██╔════╝██╔══██╗██║ ██║██╔═══██╗██╔════╝ 199 | # ███████╗█████╗ ██████╔╝██║ ██║██║ ██║███████╗ 200 | # ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║ ██║╚════██║ 201 | # ███████║███████╗██║ ██║ ╚████╔╝ ╚██████╔╝███████║ 202 | # ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ 203 | # Basic servo PWM setup. If these values are changed then the angles defined for different positions will also change 204 | # 205 | # SELECTOR SERVO ------------------------------------------------------------------------------------------------------- 206 | # 207 | [mmu_servo selector_servo] 208 | pin: mmu:MMU_SEL_SERVO 209 | maximum_servo_angle: {maximum_servo_angle} 210 | minimum_pulse_width: {minimum_pulse_width} 211 | maximum_pulse_width: {maximum_pulse_width} 212 | # 213 | # OPTIONAL GANTRY SERVO FOR TOOLHEAD FILAMENT CUTTER ------------------------------------------------------------------ 214 | # 215 | # (uncomment this section if you have a gantry servo for toolhead cutter pin) 216 | #[mmu_servo mmu_gantry_servo] 217 | #pin: {gantry_servo_pin} 218 | #maximum_servo_angle:180 219 | #minimum_pulse_width: 0.00075 220 | #maximum_pulse_width: 0.00225 221 | #initial_angle: 180 222 | 223 | 224 | # FILAMENT SENSORS ----------------------------------------------------------------------------------------------------- 225 | # ███████╗███████╗███╗ ██╗███████╗ ██████╗ ██████╗ ███████╗ 226 | # ██╔════╝██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ 227 | # ███████╗█████╗ ██╔██╗ ██║███████╗██║ ██║██████╔╝███████╗ 228 | # ╚════██║██╔══╝ ██║╚██╗██║╚════██║██║ ██║██╔══██╗╚════██║ 229 | # ███████║███████╗██║ ╚████║███████║╚██████╔╝██║ ██║███████║ 230 | # ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ 231 | # Define the pins for optional sensors in the filament path. All but the pre-gate sensors will be automatically setup as 232 | # both endstops (for homing) and sensors for visibility purposes. 233 | # 234 | # 'pre_gate_switch_pin_X' .. 'mmu_pre_gate_X' sensor detects filament at entry to MMU. X=gate number (0..N) 235 | # 'gate_switch_pin' .. 'mmu_gate' shared sensor detects filament past the gate of the MMU 236 | # or 237 | # 'post_gear_switch_pin_X' .. 'mmu_gear_X' post gear sensor for each filament 238 | # 'extruder_switch_pin' .. 'extruder' sensor detects filament just before the extruder entry 239 | # 'toolhead_switch_pin' .. 'toolhead' sensor detects filament after extruder entry 240 | # 241 | # Sync motor feedback will typically have a tension switch (most important for syncing) or both tension and compression. 242 | # Note that compression switch is useful for use as a endstop to detect hitting the extruder entrance 243 | # 'sync_feedback_tension_pin' .. pin for switch activated when filament is under tension 244 | # 'sync_feedback_compression_pin' .. pin for switch activated when filament is under compression 245 | # 246 | # Configuration is flexible: Simply define pins for any sensor you want to enable, if pin is not set (or the alias is empty) 247 | # it will be ignored. You can also just comment out what you are not using. 248 | # 249 | [mmu_sensors] 250 | pre_gate_switch_pin_0: ^mmu:MMU_PRE_GATE_0 251 | pre_gate_switch_pin_1: ^mmu:MMU_PRE_GATE_1 252 | pre_gate_switch_pin_2: ^mmu:MMU_PRE_GATE_2 253 | pre_gate_switch_pin_3: ^mmu:MMU_PRE_GATE_3 254 | pre_gate_switch_pin_4: ^mmu:MMU_PRE_GATE_4 255 | pre_gate_switch_pin_5: ^mmu:MMU_PRE_GATE_5 256 | pre_gate_switch_pin_6: ^mmu:MMU_PRE_GATE_6 257 | pre_gate_switch_pin_7: ^mmu:MMU_PRE_GATE_7 258 | pre_gate_switch_pin_8: ^mmu:MMU_PRE_GATE_8 259 | pre_gate_switch_pin_9: ^mmu:MMU_PRE_GATE_9 260 | pre_gate_switch_pin_10: ^mmu:MMU_PRE_GATE_10 261 | pre_gate_switch_pin_11: ^mmu:MMU_PRE_GATE_11 262 | 263 | post_gear_switch_pin_0: ^mmu:MMU_POST_GEAR_0 264 | post_gear_switch_pin_1: ^mmu:MMU_POST_GEAR_1 265 | post_gear_switch_pin_2: ^mmu:MMU_POST_GEAR_2 266 | post_gear_switch_pin_3: ^mmu:MMU_POST_GEAR_3 267 | post_gear_switch_pin_4: ^mmu:MMU_POST_GEAR_4 268 | post_gear_switch_pin_5: ^mmu:MMU_POST_GEAR_5 269 | post_gear_switch_pin_6: ^mmu:MMU_POST_GEAR_6 270 | post_gear_switch_pin_7: ^mmu:MMU_POST_GEAR_7 271 | post_gear_switch_pin_8: ^mmu:MMU_POST_GEAR_8 272 | post_gear_switch_pin_9: ^mmu:MMU_POST_GEAR_9 273 | post_gear_switch_pin_10: ^mmu:MMU_POST_GEAR_10 274 | post_gear_switch_pin_11: ^mmu:MMU_POST_GEAR_11 275 | 276 | # These sensors can be replicated in a multi-mmu, type-B setup (see num_gates comment). 277 | # If so, then use a comma separated list of per-unit pins instead of single pin 278 | gate_switch_pin: ^mmu:MMU_GATE_SENSOR 279 | sync_feedback_tension_pin: {sync_feedback_tension_pin} 280 | sync_feedback_compression_pin: {sync_feedback_compression_pin} 281 | 282 | # These sensors are on the toolhead and often controlled by the main printer mcu 283 | extruder_switch_pin: {extruder_sensor_pin} 284 | toolhead_switch_pin: {toolhead_sensor_pin} 285 | 286 | 287 | # ENCODER ------------------------------------------------------------------------------------------------------------- 288 | # ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ 289 | # ██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗ 290 | # █████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗ ██████╔╝ 291 | # ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗ 292 | # ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗██║ ██║ 293 | # ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ 294 | # Encoder measures distance, monitors for runout and clogging and constantly calculates % flow rate 295 | # Note that the encoder_resolution set here is purely a default to get started. It will be correcly set after calibration 296 | # with the value stored in mmu_vars.cfg 297 | # 298 | # The encoder resolution will be calibrated but it needs a default approximation 299 | # If BMG gear based: 300 | # resolution = bmg_circumfrance / (2 * teeth) 301 | # 24 / (2 * 17) = 0.7059 for TRCT5000 based sensor 302 | # 24 / (2 * 12) = 1.0 for Binky with 12 tooth disc 303 | # 304 | [mmu_encoder mmu_encoder] 305 | encoder_pin: ^mmu:MMU_ENCODER 306 | encoder_resolution: {encoder_resolution} # This is just a starter value. Overriden by calibrated 'mmu_encoder_resolution' in mmm_vars.cfg 307 | desired_headroom: 5.0 # The clog/runout headroom that MMU attempts to maintain (closest point to triggering runout) 308 | average_samples: 4 # The "damping" effect of last measurement (higher value means slower automatic clog_length reduction) 309 | flowrate_samples: 20 # How many "movements" of the extruder to measure average flowrate over 310 | 311 | 312 | # ESPOOLER (OPTIONAL) ------------------------------------------------------------------------------------------------- 313 | # ███████╗███████╗██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ 314 | # ██╔════╝██╔════╝██╔══██╗██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ 315 | # █████╗ ███████╗██████╔╝██║ ██║██║ ██║██║ █████╗ ██████╔╝ 316 | # ██╔══╝ ╚════██║██╔═══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ 317 | # ███████╗███████║██║ ╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ 318 | # ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ 319 | # 320 | # An espooler controls DC motors (typically N20) that are able to rewind a filament spool and optionally provide 321 | # forward assist to overcome spooler rotation friction. This should define pins for each of the gates on your mmu 322 | # starting with '_0'. 323 | # An empty pin can be deleted, commented or simply left blank. If you mcu has a separate "enable" pin 324 | # 325 | [mmu_espooler mmu_espooler] 326 | pwm: 1 # 1=PWM control (typical), 0=digital on/off control 327 | #hardware_pwm: 0 # See klipper doc 328 | #cycle_time: 0.100 # See klipper doc 329 | scale: 1 # Scales the PWM output range 330 | #value: 0 # See klipper doc 331 | #shutdown_value: 0 # See klipper doc 332 | 333 | respool_motor_pin_0: mmu:MMU_ESPOOLER_RWD_0 # PWM (or digital) pin for rewind/respool movement 334 | assist_motor_pin_0: mmu:MMU_ESPOOLER_FWD_0 # PWM (or digital) pin for forward motor movement 335 | enable_motor_pin_0: mmu:MMU_ESPOOLER_EN_0 # Digital output for Afc mcu 336 | assist_trigger_pin_0: mmu:MMU_ESPOOLER_TRIG_0 # Trigger pin for sensing need to assist during print 337 | 338 | respool_motor_pin_1: mmu:MMU_ESPOOLER_RWD_1 339 | assist_motor_pin_1: mmu:MMU_ESPOOLER_FWD_1 340 | enable_motor_pin_1: mmu:MMU_ESPOOLER_EN_1 341 | assist_trigger_pin_1: mmu:MMU_ESPOOLER_TRIG_1 342 | 343 | respool_motor_pin_2: mmu:MMU_ESPOOLER_RWD_2 344 | assist_motor_pin_2: mmu:MMU_ESPOOLER_FWD_2 345 | enable_motor_pin_2: mmu:MMU_ESPOOLER_EN_2 346 | assist_trigger_pin_2: mmu:MMU_ESPOOLER_TRIG_2 347 | 348 | respool_motor_pin_3: mmu:MMU_ESPOOLER_RWD_3 349 | assist_motor_pin_3: mmu:MMU_ESPOOLER_FWD_3 350 | enable_motor_pin_3: mmu:MMU_ESPOOLER_EN_3 351 | assist_trigger_pin_3: mmu:MMU_ESPOOLER_TRIG_3 352 | 353 | 354 | # MMU OPTIONAL NEOPIXEL LED SUPPORT ------------------------------------------------------------------------------------ 355 | # ██╗ ███████╗██████╗ ███████╗ 356 | # ██║ ██╔════╝██╔══██╗██╔════╝ 357 | # ██║ █████╗ ██║ ██║███████╗ 358 | # ██║ ██╔══╝ ██║ ██║╚════██║ 359 | # ███████╗███████╗██████╔╝███████║ 360 | # ╚══════╝╚══════╝╚═════╝ ╚══════╝ 361 | # Define the led connection, type and length 362 | # 363 | # (comment out this section if you don't have leds or have them defined elsewhere) 364 | [neopixel mmu_leds] 365 | pin: mmu:MMU_NEOPIXEL 366 | chain_count: {chain_count} # Need number gates x1 or x2 + status leds 367 | color_order: {color_order} # Set based on your particular neopixel specification (can be comma separated list) 368 | 369 | # MMU LED EFFECT SEGMENTS ---------------------------------------------------------------------------------------------- 370 | # Define neopixel LEDs for your MMU. The chain_count must be large enough for your desired ranges: 371 | # exit .. this set of LEDs, one for every gate, usually would be mounted at the exit point of the gate 372 | # entry .. this set of LEDs, one for every gate, could be mounted at the entry point of filament into the MMU/buffer 373 | # status .. these LED. represents the status of the MMU (and selected filament). More than one status LED is possible 374 | # logo .. these LEDs don't change during operation and are designed for driving a logo. More than one logo LED is possible 375 | # 376 | # Note that all sets are optional. You can opt to just have the 'exit' set for example. The advantage to having 377 | # both entry and exit LEDs is, for example, so that 'entry' can display gate status while 'exit' displays the color 378 | # 379 | # The animation effects requires the installation of Julian Schill's awesome LED effect module otherwise the LEDs 380 | # will be static: 381 | # https://github.com/julianschill/klipper-led_effect 382 | # 383 | # 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: 384 | # 385 | # exit_leds: neopixel:mmu_leds (1,2,3,4) 386 | # status_leds: neopixel:mmu_leds (5) 387 | # 388 | # In this example no 'entry' set is configured. Note that constructs like "mmu_leds (1-3,4)" are also valid 389 | # 390 | # The range is completely flexible and can be comprised of different led strips, individual LEDs, or combinations of 391 | # both on different pins. In addition, the ordering is flexible based on your wiring, thus (1-4) and (4-1) both represent 392 | # the same LED range but mapped to increasing or decreasing gates respectively. E.g if you have two Box Turtle MMUs, one 393 | # with a chain of LEDs wired in reverse order and another with individual LEDs, to define 8 exit LEDs: 394 | # 395 | # exit_leds: neopixel:bt_1 (4-1) 396 | # neopixel:bt_2a 397 | # neopixel:bt_2b 398 | # neopixel:bt_2c 399 | # neopixel:bt_2d 400 | # 401 | # Note the use of separate lines for each part of the definition, 402 | # 403 | # ADVANCED: Happy Hare provides a convenience wrapper [mmu_led_effect] that not only creates an effect on each of the 404 | # [mmu_leds] specified segments as a whole but also each individual LED for atomic control. See mmu_leds.cfg for examples 405 | # 406 | # (comment out this whole section if you don't have/want leds; uncomment/edit LEDs fitted on your MMU) 407 | [mmu_leds] 408 | exit_leds: {exit_leds} 409 | entry_leds: {entry_leds} 410 | status_leds: {status_leds} 411 | logo_leds: {logo_leds} 412 | frame_rate: 24 413 | -------------------------------------------------------------------------------- /config/base/mmu_leds.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # Supporting macros 4 | # 5 | # THIS FILE IS READ ONLY 6 | # 7 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 8 | # moggieuk@hotmail.com 9 | # This file may be distributed under the terms of the GNU GPLv3 license. 10 | # 11 | # Goal: Support for controlling optional LEDs called from Happy Hare state change events 12 | # 13 | # (\_/) 14 | # ( *,*) 15 | # (")_(") Happy Hare Ready 16 | # 17 | # 18 | [gcode_macro _MMU_LED_ACTION_CHANGED] 19 | description: Called when an action has changed to update LEDs 20 | gcode: 21 | {% set action = params.ACTION|string %} 22 | {% set old_action = params.OLD_ACTION|string %} 23 | {% set gate = printer['mmu']['gate'] %} 24 | 25 | {% if action == "Loading" %} 26 | _MMU_SET_LED GATE={gate} EXIT_EFFECT=mmu_blue_slow_exit STATUS_EFFECT=mmu_blue_slow_status 27 | {% elif action == "Loading Ext" %} 28 | _MMU_SET_LED GATE={gate} EXIT_EFFECT=mmu_blue_fast_exit STATUS_EFFECT=mmu_blue_fast_status 29 | {% elif old_action == "Exiting Ext" %} 30 | _MMU_SET_LED GATE={gate} EXIT_EFFECT=mmu_blue_slow_exit STATUS_EFFECT=mmu_blue_slow_status 31 | {% elif action == "Unloading" %} 32 | _MMU_SET_LED GATE={gate} EXIT_EFFECT=mmu_blue_fast_exit STATUS_EFFECT=mmu_blue_fast_status 33 | {% elif action == "Heating" %} 34 | _MMU_SET_LED GATE={gate} EXIT_EFFECT=mmu_breathing_red_exit STATUS_EFFECT=mmu_breathing_red_status 35 | {% elif action == "Idle" %} 36 | _MMU_SET_LED EXIT_EFFECT=default STATUS_EFFECT=default 37 | {% elif action == "Homing" or action == "Selecting" %} 38 | {% if old_action != "Homing" and old_action != "Checking" %} 39 | _MMU_SET_LED EXIT_EFFECT=mmu_white_fast_exit STATUS_EFFECT=off FADETIME=0 40 | {% endif %} 41 | {% elif action == "Checking" %} 42 | _MMU_SET_LED EXIT_EFFECT=default STATUS_EFFECT=mmu_white_fast_status 43 | {% endif %} 44 | 45 | 46 | [gcode_macro _MMU_LED_PRINT_STATE_CHANGED] 47 | description: Called when print state changes to update LEDs 48 | gcode: 49 | {% set state = params.STATE|string %} 50 | {% set old_state = params.OLD_STATE|string %} 51 | {% set gate = printer['mmu']['gate'] %} 52 | 53 | {% if state == "initialized" %} 54 | _MMU_SET_LED EXIT_EFFECT=mmu_rainbow_exit ENTRY_EFFECT=mmu_rainbow_entry DURATION=8 55 | {% elif state == "printing" %} 56 | _MMU_SET_LED EXIT_EFFECT=default ENTRY_EFFECT=default STATUS_EFFECT=default 57 | {% elif state == "pause_locked" %} 58 | _MMU_SET_LED EXIT_EFFECT=mmu_strobe_exit STATUS_EFFECT=mmu_strobe_status 59 | {% elif state == "paused" %} 60 | _MMU_SET_LED GATE={gate} EXIT_EFFECT=mmu_strobe_exit STATUS_EFFECT=mmu_strobe_status 61 | {% elif state == "ready" %} 62 | _MMU_SET_LED EXIT_EFFECT=default ENTRY_EFFECT=default STATUS_EFFECT=default 63 | {% elif state == "complete" %} 64 | _MMU_SET_LED EXIT_EFFECT=mmu_sparkle_exit STATUS_EFFECT=default DURATION=20 65 | {% elif state == "error" %} 66 | _MMU_SET_LED EXIT_EFFECT=mmu_strobe_exit STATUS_EFFECT=default DURATION=20 67 | {% elif state == "cancelled" %} 68 | _MMU_SET_LED EXIT_EFFECT=default ENTRY_EFFECT=default STATUS_EFFECT=default 69 | {% elif state == "standby" %} 70 | _MMU_SET_LED EXIT_EFFECT=off ENTRY_EFFECT=off STATUS_EFFECT=off LOGO_EFFECT=off 71 | {% endif %} 72 | 73 | 74 | [gcode_macro _MMU_LED_GATE_MAP_CHANGED] 75 | description: Called when gate map is updated to update LEDs 76 | gcode: 77 | {% set gate = params.GATE|int %} 78 | {% set set_led_vars = printer['gcode_macro _MMU_SET_LED'] %} 79 | 80 | {% set current = set_led_vars['current_exit_effect'] %} 81 | {% set exit_effect=current if current in ["gate_status", "filament_color", "slicer_color"] else "" %} 82 | {% set current = set_led_vars['current_entry_effect'] %} 83 | {% set entry_effect=current if current in ["gate_status", "filament_color", "slicer_color"] else "" %} 84 | {% set current = set_led_vars['current_status_effect'] %} 85 | {% set status_effect=current if current in ["filament_color", "slicer_color"] else "" %} 86 | 87 | {% if exit_effect != "" or entry_effect != "" or status_effect != "" %} 88 | _MMU_SET_LED EXIT_EFFECT={exit_effect} ENTRY_EFFECT={entry_effect} STATUS_EFFECT={status_effect} 89 | {% endif %} 90 | 91 | 92 | ########################################################################### 93 | # Support macro for MMU neopixel leds 94 | # 95 | # Effects for LED segments when not providing action status can be 96 | # any effect name, "r,g,b" color, or built-in functional effects: 97 | # "off" - LED's off 98 | # "on" - LED's white 99 | # "gate_status" - indicate gate availability 100 | # "filament_color" - indicate filament color 101 | # "slicer_color" - display slicer defined color for each gate 102 | # 103 | [gcode_macro _MMU_SET_LED] 104 | description: Called when print state changes 105 | 106 | # -------------------------- Internal Don't Touch ------------------------- 107 | variable_current_exit_effect: "none" 108 | variable_current_entry_effect: "none" 109 | variable_current_status_effect: "none" 110 | variable_current_logo_effect: "none" 111 | 112 | gcode: 113 | {% set mmu_leds = printer['mmu_leds'] %} 114 | {% set vars = printer['gcode_macro _MMU_LED_VARS'] %} 115 | 116 | {% if mmu_leds is defined and vars['led_enable'] %} 117 | {% set gate = params.GATE|default(-1)|int %} 118 | {% set duration = params.DURATION|default(-1)|int %} 119 | {% set fadetime = params.FADETIME|default(1)|int %} 120 | 121 | # Grab useful printer variables 122 | {% set mmu = printer['mmu'] %} 123 | {% set gate_status = mmu['gate_status'] %} 124 | {% set gate_color = mmu['gate_color'] %} 125 | {% set gate_color_rgb = mmu['gate_color_rgb'] %} 126 | {% set slicer_color_rgb = mmu['slicer_color_rgb'] %} 127 | {% set filament_pos = mmu['filament_pos'] %} 128 | {% set current_gate = mmu['gate'] %} 129 | {% set is_loaded = mmu['filament'] == 'Loaded' %} 130 | {% set num_gates = mmu['num_gates'] %} 131 | {% set index = gate + 1 %} # LEDs are 1-based 132 | 133 | # Build effects dict disabling non configured segments 134 | {% set effects = { 135 | 'exit': ( 136 | "" if mmu_leds['exit'] == 0 137 | else ( 138 | vars['default_exit_effect'] if params.EXIT_EFFECT|default("")|string == "default" 139 | else params.EXIT_EFFECT|default("")|string 140 | ) 141 | ), 142 | 'entry': ( 143 | "" if mmu_leds['entry'] == 0 144 | else ( 145 | vars['default_entry_effect'] if params.ENTRY_EFFECT|default("")|string == "default" 146 | else params.ENTRY_EFFECT|default("")|string 147 | ) 148 | ), 149 | 'status': ( 150 | "" if mmu_leds['status'] == 0 151 | else ( 152 | vars['default_status_effect'] if params.STATUS_EFFECT|default("")|string == "default" 153 | else params.STATUS_EFFECT|default("")|string 154 | ) 155 | ), 156 | 'logo': ( 157 | "" if mmu_leds['logo'] == 0 158 | else ( 159 | vars['default_logo_effect'] if params.LOGO_EFFECT|default("")|string == "default" 160 | else params.LOGO_EFFECT|default("")|string 161 | ) 162 | ) 163 | } %} 164 | 165 | {% if not effects['exit'] == "" %} 166 | SET_GCODE_VARIABLE MACRO=_MMU_SET_LED VARIABLE=current_exit_effect VALUE='"{effects['exit']}"' 167 | {% endif %} 168 | {% if not effects['entry'] == "" %} 169 | SET_GCODE_VARIABLE MACRO=_MMU_SET_LED VARIABLE=current_entry_effect VALUE='"{effects['entry']}"' 170 | {% endif %} 171 | {% if not effects['status'] == "" %} 172 | SET_GCODE_VARIABLE MACRO=_MMU_SET_LED VARIABLE=current_status_effect VALUE='"{effects['status']}"' 173 | {% endif %} 174 | 175 | {% if duration >= 0 %} 176 | UPDATE_DELAYED_GCODE ID=_MMU_RESET_LED DURATION={duration} 177 | {% endif %} 178 | 179 | {% for segment in ['exit', 'entry'] %} 180 | 181 | {% if effects[segment] == "off" %} 182 | {% if gate >= 0 %} 183 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds ({index})" FADETIME={fadetime} 184 | SET_LED LED=mmu_{segment}_leds INDEX={index} RED=0 GREEN=0 BLUE=0 TRANSMIT=1 185 | {% else %} 186 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" FADETIME={fadetime} 187 | {% for i in range(1, num_gates + 1) %} 188 | SET_LED LED=mmu_{segment}_leds INDEX={i} RED=0 GREEN=0 BLUE=0 TRANSMIT={1 if loop.last else 0} 189 | {% endfor %} 190 | {% endif %} 191 | 192 | {% elif effects[segment] == "gate_status" %} # Filament availability 193 | {% if gate >= 0 %} 194 | {% if gate == current_gate and is_loaded %} 195 | _SET_LED_EFFECT EFFECT=mmu_blue_{segment}_{index} FADETIME={fadetime} REPLACE=1 196 | {% elif gate_status[gate] == -1 %} 197 | _SET_LED_EFFECT EFFECT=mmu_orange_{segment}_{index} FADETIME={fadetime} REPLACE=1 198 | {% elif gate_status[gate] > 0 %} 199 | _SET_LED_EFFECT EFFECT=mmu_green_{segment}_{index} FADETIME={fadetime} REPLACE=1 200 | {% else %} 201 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds ({index})" FADETIME={fadetime} 202 | {% endif %} 203 | {% else %} 204 | {% for status in gate_status %} 205 | {% if loop.index0 == current_gate and is_loaded %} 206 | _SET_LED_EFFECT EFFECT=mmu_blue_{segment}_{loop.index} FADETIME={fadetime} REPLACE=1 207 | {% elif status == -1 %} 208 | _SET_LED_EFFECT EFFECT=mmu_orange_{segment}_{loop.index} FADETIME={fadetime} REPLACE=1 209 | {% elif status > 0 %} 210 | _SET_LED_EFFECT EFFECT=mmu_green_{segment}_{loop.index} FADETIME={fadetime} REPLACE=1 211 | {% else %} 212 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds ({loop.index})" FADETIME={fadetime} 213 | SET_LED LED=mmu_{segment}_leds INDEX={loop.index} RED=0 GREEN=0 BLUE=0 TRANSMIT=1 214 | {% endif %} 215 | {% endfor %} 216 | {% endif %} 217 | 218 | {% elif effects[segment] == "filament_color" %} # Filament color 219 | {% if gate >= 0 %} 220 | {% set rgb = gate_color_rgb[gate] %} 221 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds ({index})" 222 | SET_LED LED=mmu_{segment}_leds INDEX={index} RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 223 | {% else %} 224 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" 225 | {% for rgb in gate_color_rgb %} 226 | {% set current_gate = loop.index0 %} 227 | {% if gate_status[current_gate] != 0 %} 228 | {% if gate_color[current_gate] == "" %} 229 | {% set rgb = vars['white_light'] %} 230 | {% elif rgb == (0,0,0) %} 231 | {% set rgb = vars['black_light'] %} 232 | {% endif %} 233 | {% else %} 234 | {% set rgb = vars['empty_light'] %} 235 | {% endif %} 236 | SET_LED LED=mmu_{segment}_leds INDEX={loop.index} RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT={1 if loop.last else 0} 237 | {% endfor %} 238 | {% endif %} 239 | 240 | {% elif effects[segment] == "slicer_color" %} # Slicer color 241 | {% if gate >= 0 %} 242 | {% set rgb = slicer_color_rgb[gate] %} 243 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds ({index})" 244 | SET_LED LED=mmu_{segment}_leds INDEX={index} RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 245 | {% else %} 246 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" 247 | {% for rgb in slicer_color_rgb %} 248 | SET_LED LED=mmu_{segment}_leds INDEX={loop.index} RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT={1 if loop.last else 0} 249 | {% endfor %} 250 | {% endif %} 251 | 252 | {% elif "," in effects[segment] %} # Not effect, just simple RGB color 253 | {% set rgb = effects[segment].split(",") | map('trim') | list %} 254 | {% if gate >= 0 %} 255 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds ({index})" 256 | SET_LED LED=mmu_{segment}_leds INDEX={index} RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 257 | {% else %} 258 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" 259 | {% for i in range(1, num_gates + 1) %} 260 | SET_LED LED=mmu_{segment}_leds INDEX={i} RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT={1 if loop.last else 0} 261 | {% endfor %} 262 | {% endif %} 263 | 264 | {% elif effects[segment] != "" %} # Simple effect by name 265 | {% if gate >= 0 %} 266 | _SET_LED_EFFECT EFFECT={effects[segment]}_{index} FADETIME={fadetime} REPLACE=1 267 | {% else %} 268 | _SET_LED_EFFECT EFFECT={effects[segment]} FADETIME={fadetime} REPLACE=1 269 | {% endif %} 270 | {% endif %} 271 | {% endfor %} 272 | 273 | # Status LED effects... 274 | {% set segment = "status" %} 275 | {% if effects[segment] == "off" %} 276 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" FADETIME={fadetime} 277 | SET_LED LED=mmu_{segment}_leds RED=0 GREEN=0 BLUE=0 TRANSMIT=1 278 | 279 | {% elif effects[segment] in ["filament_color", "on"] %} # Filament color or On 280 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" 281 | {% if current_gate >= 0 and filament_pos > 0 %} 282 | {% if status_effect != "on" and gate_color[current_gate] != "" %} 283 | {% set rgb = gate_color_rgb[current_gate] %} 284 | {% if rgb == (0,0,0) %} 285 | {% set rgb = vars['black_light'] %} 286 | {% endif %} 287 | {% else %} 288 | {% set rgb = vars['white_light'] %} 289 | {% endif %} 290 | SET_LED LED=mmu_{segment}_leds RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 291 | {% else %} 292 | SET_LED LED=mmu_{segment}_leds RED=0 GREEN=0 BLUE=0 TRANSMIT=1 293 | {% endif %} 294 | 295 | {% elif effects[segment] == "slicer_color" %} # Slicer color 296 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" 297 | {% if current_gate >= 0 and filament_pos > 0 %} 298 | {% set rgb = slicer_color_rgb[current_gate] %} 299 | SET_LED LED=mmu_{segment}_leds RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 300 | {% else %} 301 | SET_LED LED=mmu_{segment}_leds RED=0 GREEN=0 BLUE=0 TRANSMIT=1 302 | {% endif %} 303 | 304 | {% elif "," in effects[segment] %} # No effect, just simple RGB color 305 | {% set rgb = effects[segment].split(",") | map('trim') | list %} 306 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" 307 | SET_LED LED=mmu_{segment}_leds RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 308 | 309 | {% elif effects[segment] != "" %} # Simple effect by name 310 | _SET_LED_EFFECT EFFECT={effects[segment]} FADETIME={fadetime} REPLACE=1 311 | {% endif %} 312 | 313 | # Logo LED effects... 314 | {% set segment = "logo" %} 315 | {% if effects[segment] == "off" %} 316 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" FADETIME={fadetime} 317 | SET_LED LED=mmu_{segment}_leds RED=0 GREEN=0 BLUE=0 TRANSMIT=1 318 | 319 | {% elif "," in effects[segment] %} # No effect, just simple RGB color 320 | {% set rgb = effects[segment].split(",") | map('trim') | list %} 321 | _STOP_LED_EFFECTS LEDS="mmu_{segment}_leds" 322 | SET_LED LED=mmu_{segment}_leds RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 323 | 324 | {% elif effects[segment] != "" %} # Simple effect by name 325 | _SET_LED_EFFECT EFFECT={effects[segment]} FADETIME={fadetime} REPLACE=1 326 | {% endif %} 327 | {% endif %} 328 | 329 | 330 | ########################################################################### 331 | # Helper for LED control 332 | # 333 | [delayed_gcode _MMU_RESET_LED] 334 | gcode: 335 | _MMU_SET_LED EXIT_EFFECT=default ENTRY_EFFECT=default STATUS_EFFECT=default LOGO_EFFECT=default 336 | 337 | 338 | ########################################################################### 339 | # Helpers to support static LED without led-effects module installed 340 | # 341 | [gcode_macro _STOP_LED_EFFECTS] 342 | description: Helper to convert effect into simple static LED 343 | gcode: 344 | {% set vars = printer['gcode_macro _MMU_LED_VARS'] %} 345 | {% set leds = params.LEDS|string %} 346 | {% set fadetime = params.FADETIME|default(0.0)|float %} 347 | 348 | {% if vars['led_animation'] %} 349 | STOP_LED_EFFECTS LEDS="{leds}" FADETIME={fadetime} 350 | {% endif %} 351 | 352 | [gcode_macro _SET_LED_EFFECT] 353 | description: Helper to convert effect into simple static LED 354 | variable_effect_to_static_color: { 355 | 'mmu_breathing_red': (0.3,0,0), 356 | 'mmu_white_slow': (0.8,0.8,0.8), 357 | 'mmu_white_fast': (0.2,0.2,0.2), 358 | 'mmu_blue_slow': (0,0,1), 359 | 'mmu_blue_fast': (0,0,1), 360 | 'mmu_strobe': (1,0,0), 361 | 'mmu_green': (0,0.5,0), 362 | 'mmu_orange': (0.5,0.2,0), 363 | 'mmu_blue': (0,0,1), 364 | 'mmu_curtain': (0.5,0.2,0), 365 | 'mmu_sparkle': (0.3,0.3,0.3), 366 | 'mmu_rainbow': (0.5,0.2,0), 367 | } 368 | gcode: 369 | {% set vars = printer['gcode_macro _MMU_LED_VARS'] %} 370 | {% set effect = params.EFFECT|string %} 371 | {% set fadetime = params.FADETIME|default(0.0)|float %} 372 | {% set num_gates = printer['mmu']['num_gates'] %} 373 | 374 | {% if vars['led_animation'] %} 375 | SET_LED_EFFECT EFFECT={effect} FADETIME={fadetime} REPLACE=1 376 | {% else %} 377 | # Break down effect string. Can't use regexp, so... 378 | {% set parts = effect.split('_') %} 379 | {% if parts[-1].isdigit() %} 380 | {% set index = parts.pop() %} 381 | {% else %} 382 | {% set index = None %} 383 | {% endif %} 384 | {% set segment = parts.pop() %} 385 | {% set effect_name = parts|join('_') %} 386 | 387 | {% set rgb = effect_to_static_color.get(effect_name, (0,0,0)) %} 388 | {% if index %} 389 | SET_LED LED=mmu_{segment}_leds INDEX={index} RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 390 | {% else %} 391 | SET_LED LED=mmu_{segment}_leds RED={rgb[0]} GREEN={rgb[1]} BLUE={rgb[2]} TRANSMIT=1 392 | {% endif %} 393 | {% endif %} 394 | 395 | ########################################################################### 396 | # Define LED effects used in control macros above 397 | # (requires [mmu_leds] setup in mmu_hardware.cfg else this will have no 398 | # effect and can be left as-is so it is ready when you want to add LEDs) 399 | # 400 | # [mmu_led_effect] is a simple wrapper that makes it easy to define based on you MMU setup 401 | # 402 | # E.g. If you have setup the following config in mmu_hardware.cfg for 4-gate MMU 403 | # [mmu_leds] 404 | # exit_leds: neopixel:mmu_leds (1-4) 405 | # status_leds: neopixel:mmu_leds (5) 406 | # 407 | # E.g. You define "my_flash" like this: 408 | # [mmu_led_effect my_flash] 409 | # 410 | # This will create effects on each of these segments elements without laborous 411 | # error prone repetition: 412 | # "mmu_flash_exit" on 'exit' portion of the strip (leds 1,2,3,4) 413 | # "mmu_flash_status" on the status LED (led 5) 414 | # "mmu_flash_exit_1" for gate 0 (led 1) 415 | # "mmu_flash_exit_2" for gate 1 (led 2) 416 | # "mmu_flash_exit_3" for gate 2 (led 3) 417 | # "mmu_flash_exit_4" for gate 3 (led 4) 418 | # 419 | # Then you can set effects with commands like: 420 | # SET_LED_EFFECT EFFECT=my_flash_exit # apply effect to all exit leds 421 | # SET_LED_EFFECT EFFECT=my_flash_exit_2 # apply effect entry led for gate #1 422 | # 423 | # or set simple RBGW color with commands like: 424 | # SET_LED LED=mmu_exit_led INDEX=2 RED=50 GREEN=50 BLUE=50 WHITE=0 TRANSMIT=1 425 | # 426 | # Note that gates start at 0, but led indices and effect naming starts from 1, 427 | # so remember: led_index = gate + 1 428 | # 429 | [mmu_led_effect mmu_breathing_red] 430 | layers: breathing 4 0 top (1,0,0) 431 | 432 | [mmu_led_effect mmu_white_slow] 433 | layers: breathing 1.0 0 top (0.8,0.8,0.8) 434 | 435 | [mmu_led_effect mmu_white_fast] 436 | layers: breathing 0.6 0 top (0.2,0.2,0.2) 437 | 438 | [mmu_led_effect mmu_blue_slow] 439 | layers: breathing 1.0 0 top (0,0,1) 440 | 441 | [mmu_led_effect mmu_blue_fast] 442 | layers: breathing 0.6 0 top (0,0,1) 443 | 444 | [mmu_led_effect mmu_strobe] 445 | layers: strobe 1 1.5 add (1,1,1) 446 | breathing 2 0 difference (0.95,0,0) 447 | static 0 0 top (1,0,0) 448 | 449 | [mmu_led_effect mmu_green] 450 | layers: static 0 0 top (0,0.5,0) 451 | 452 | [mmu_led_effect mmu_orange] 453 | layers: static 0 0 top (0.5,0.2,0) 454 | 455 | [mmu_led_effect mmu_blue] 456 | layers: static 0 0 top (0,0,1) 457 | 458 | [mmu_led_effect mmu_curtain] 459 | define_on: exit 460 | layers: comet -1.5 1.0 add (0.4,0.4,0.4), (0,0,1) 461 | comet 1.5 1.0 top (0.4,0.4,0.4), (1,0,0) 462 | 463 | [mmu_led_effect mmu_sparkle] 464 | define_on: exit 465 | layers: twinkle 8 0.15 top (0.3,0.3,0.3), (0.4,0,0.25) 466 | 467 | [mmu_led_effect mmu_rainbow] 468 | define_on: entry,exit,status 469 | 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) 470 | -------------------------------------------------------------------------------- /config/base/mmu_purge.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # Supporting macros 4 | # 5 | # THIS FILE IS READ ONLY 6 | # 7 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 8 | # moggieuk@hotmail.com 9 | # This file may be distributed under the terms of the GNU GPLv3 license. 10 | # 11 | # Goal: Standalone (very simplistic reference) filament purging 12 | # 13 | # (\_/) 14 | # ( *,*) 15 | # (")_(") Happy Hare Ready 16 | # 17 | # 18 | # When using this macro in print it is important to turn off the wipetower in your slicer 19 | # (read the wiki: Slicer Setup & Toolchange-Movement pages) 20 | # Then set the following parameters in mmu_parameters.cfg: 21 | # 22 | # purge_macro: _MMU_PURGE 23 | # force_purge_standalone: 1 24 | # 25 | [gcode_macro _MMU_PURGE] 26 | description: Simple reference filament purge 27 | 28 | gcode: 29 | # Happy Hare retraction settings from sequence macros 30 | {% set sequence_vars = printer['gcode_macro _MMU_SEQUENCE_VARS'] %} 31 | {% set park_vars = printer['gcode_macro _MMU_PARK'] %} 32 | {% set retracted_length = park_vars.retracted_length %} 33 | {% set retract_speed = sequence_vars.retract_speed|int %} 34 | {% set unretract_speed = sequence_vars.unretract_speed|int %} 35 | 36 | # Happy Hare provided purge data 37 | {% set toolchange_purge_volume = printer.mmu.toolchange_purge_volume|default(0)|float %} 38 | {% set extruder_filament_remaining = printer.mmu.extruder_filament_remaining|default(0)|float %} 39 | 40 | # Not used in reference macro but full purge volume matrix from the slicer can be loaded like this 41 | # https://github.com/moggieuk/Happy-Hare/wiki/Gcode-Preprocessing) 42 | {% set pv = printer.mmu.slicer_tool_map.purge_volumes %} 43 | 44 | # Calculate amount of filament to purge 45 | {% set filament_diameter = printer.configfile.config.extruder.filament_diameter|float %} 46 | {% set filament_cross_section = (filament_diameter / 2) ** 2 * 3.1415 %} 47 | {% set purge_len = (toolchange_purge_volume / filament_cross_section) + extruder_filament_remaining %} 48 | {% set segment_len = 2.0 %} 49 | 50 | # Undo Happy Hare retraction before starting purge 51 | {% if retracted_length > 0 %} 52 | MMU_LOG MSG="Un-retracting {retracted_length}mm" 53 | M83 ; Extruder relative 54 | G1 E{retracted_length} F{unretract_speed|abs * 60} 55 | {% endif %} 56 | 57 | MMU_LOG MSG="Purging {purge_len | round(1)}mm of filament" 58 | 59 | # Purge in segments so it is still possible to detect clogs and pause 60 | {% set num_segments = (purge_len // segment_len) | int %} 61 | {% for _ in range(num_segments) %} 62 | __MMU_PURGE_SEGMENT LENGTH={segment_len} 63 | {% endfor %} 64 | __MMU_PURGE_SEGMENT LENGTH={purge_len % segment_len} 65 | 66 | # Retract to match what Happy Hare is expecting 67 | {% if retracted_length > 0 %} 68 | MMU_LOG MSG="Retracting {retracted_length}mm" 69 | M83 ; Extruder relative 70 | G1 E-{retracted_length} F{retract_speed|abs * 60} 71 | {% endif %} 72 | 73 | MMU_LOG MSG="Purging complete" 74 | 75 | 76 | # Helper that allows for check of "runout/clog" indicator 77 | [gcode_macro __MMU_PURGE_SEGMENT] 78 | gcode: 79 | {% set vars = printer['gcode_macro _MMU_PURGE_VARS'] %} 80 | {% set extruder_purge_speed = vars['extruder_purge_speed']|float %} 81 | {% set length = params.LENGTH|float %} 82 | {% set clog_runout_detected = printer.mmu.clog_runout_detected|default(false)|lower == 'true' %} # TODO Future 83 | 84 | {% if not clog_runout_detected %} 85 | G1 E{length} F{extruder_purge_speed * 60} 86 | {% endif %} 87 | -------------------------------------------------------------------------------- /config/base/mmu_state.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # Supporting macros 4 | # 5 | # THIS FILE IS READ ONLY 6 | # 7 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 8 | # moggieuk@hotmail.com 9 | # This file may be distributed under the terms of the GNU GPLv3 license. 10 | # 11 | # Goal: Callouts for Happy Hare state changes 12 | # 13 | # (\_/) 14 | # ( *,*) 15 | # (")_(") Happy Hare Ready 16 | # 17 | 18 | 19 | ########################################################################### 20 | # Called when when the MMU action status changes 21 | # 22 | # The `ACTION` parameter will contain the current action string 23 | # (also available in `printer.mmu.action` printer variable). 24 | # Also the previous action is available in `OLD_ACTION`. 25 | # 26 | # See Happy Hare README for full list of action strings, but a quick ref is: 27 | # 28 | # Idle|Loading|Unloading|Loading Ext|Exiting Ext|Heating|Checking|Homing|Selecting 29 | # Forming Tip|Cutting Tip|Cutting Filament|Purging 30 | # 31 | # The reference logic here drives a set of optional LED's 32 | # 33 | [gcode_macro _MMU_ACTION_CHANGED] 34 | description: Called when an action has changed 35 | gcode: 36 | {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} 37 | {% set action = params.ACTION|string %} 38 | {% set old_action = params.OLD_ACTION|string %} 39 | 40 | _MMU_LED_ACTION_CHANGED {rawparams} 41 | 42 | {% if not vars.user_action_changed_extension == "" %} 43 | {vars.user_action_changed_extension} {rawparams} 44 | {% endif %} 45 | 46 | 47 | ########################################################################### 48 | # Called when the MMU print state changes 49 | # 50 | # The `STATE` parameter will contain the current state string 51 | # (also available in `printer.mmu.print_state` printer variable) 52 | # Also the previous action is available in `OLD_STATE`. 53 | # 54 | # See Happy Hare README for full list of state strings and the state transition 55 | # diagram, but a quick ref is: 56 | # 57 | # initialized|ready|started|printing|complete|cancelled|error|pause_locked|paused|standby 58 | # 59 | # The reference logic here drives a set of optional LED's 60 | # 61 | [gcode_macro _MMU_PRINT_STATE_CHANGED] 62 | description: Called when print state changes 63 | gcode: 64 | {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} 65 | {% set state = params.STATE|string %} 66 | {% set old_state = params.OLD_STATE|string %} 67 | 68 | _MMU_LED_PRINT_STATE_CHANGED {rawparams} 69 | 70 | {% if not vars.user_print_state_changed_extension == "" %} 71 | {vars.user_print_state_changed_extension} {rawparams} 72 | {% endif %} 73 | 74 | 75 | ########################################################################### 76 | # Called when an atomic event occurs. Different from ACTION_CHANGE because 77 | # these are not necessarily part of any important state change but rather 78 | # informational 79 | # 80 | # The `EVENT` parameter will contain the event name. Other parameters 81 | # depend on the event type 82 | # 83 | # See Happy Hare README for full list of event strings, but a quick ref is: 84 | # 85 | # Events: 86 | # "restart" Called when Happy Hare starts / restarts 87 | # Parameters: None 88 | # 89 | # "gate_map_changed" Called when the MMU gate_map (containing information 90 | # about the filament type, color, availability and 91 | # spoolId) is updated 92 | # Parameters: GATE The gate that is updated or -1 if all updated 93 | # 94 | # "filament_gripped" Called when MMU servo (if fitted) grips filament 95 | # Parameters: None 96 | # 97 | # "filament_cut" Called when filament is cut 98 | # Parameters: None 99 | # 100 | # The reference logic here updates counters and drives optional LED's 101 | # 102 | [gcode_macro _MMU_EVENT] 103 | description: Called when certain MMU actions occur 104 | gcode: 105 | {% set vars = printer['gcode_macro _MMU_STATE_VARS'] %} 106 | {% set servo_down_limit = vars.servo_down_limit|default(-1)|int %} 107 | {% set cutter_blade_limit = vars.cutter_blade_limit|default(-1)|int %} 108 | {% set event = params.EVENT|string %} 109 | 110 | {% if event == "restart" %} 111 | MMU_STATS COUNTER=mmu_restarts INCR=1 112 | 113 | {% set vendor = printer.configfile.config.mmu_machine.mmu_vendor|string|lower %} 114 | {% set version = printer.configfile.config.mmu_machine.mmu_version|string|lower %} 115 | {% if vendor == "ercf" %} 116 | MMU_STATS COUNTER=servo_down LIMIT={servo_down_limit} WARNING="Inspect servo arm for wear/damage" 117 | MMU_STATS COUNTER=cutter_blade LIMIT={cutter_blade_limit} WARNING="Inspect/replace filament cutting blade" 118 | {% elif vendor == "tradrack" %} 119 | MMU_STATS COUNTER=servo_down LIMIT={servo_down_limit} WARNING="Inspect servo mechanism for wear/damage" 120 | MMU_STATS COUNTER=cutter_blade LIMIT={cutter_blade_limit} WARNING="Inspect/replace filament cutting blade" 121 | {% endif %} 122 | 123 | {% elif event == "gate_map_changed" %} 124 | _MMU_LED_GATE_MAP_CHANGED {rawparams} 125 | {% elif event == "filament_gripped" %} 126 | MMU_STATS COUNTER=servo_down INCR=1 127 | {% elif event == "filament_cut" %} 128 | MMU_STATS COUNTER=cutter_blade INCR=1 129 | {% endif %} 130 | 131 | {% if not vars.user_mmu_event_extension == "" %} 132 | {vars.user_mmu_event_extension} {rawparams} 133 | {% endif %} 134 | 135 | -------------------------------------------------------------------------------- /config/mmu_vars.cfg: -------------------------------------------------------------------------------- 1 | # This is the template file for storing Happy Hare state and calibration variables. It is pointed to 2 | # with the [save_variables] block in 'mmu_macro_vars.cfg' 3 | # 4 | # If you want to use an existing "variables" file, then that is fine but make sure you copy the 5 | # "mmu__revision" line to it because Happy Hare will look for this to validate correct setup 6 | # 7 | [Variables] 8 | mmu__revision = 0 9 | -------------------------------------------------------------------------------- /config/optional/client_macros.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # Supporting macros 4 | # 5 | # THIS FILE IS READ ONLY 6 | # 7 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 8 | # moggieuk@hotmail.com 9 | # 10 | # Portions integrated from mainsail.cfg 11 | # Copyright (C) 2022 Alex Zellner 12 | # 13 | # This file may be distributed under the terms of the GNU GPLv3 license. 14 | # 15 | # Goal: Complimentary and functional "client" macros that work with MMU enabled or disabled 16 | # 17 | # (\_/) 18 | # ( *,*) 19 | # (")_(") Happy Hare Ready 20 | # 21 | # 22 | # These are the recommended PAUSE/RESUME/CANCEL_PRINT macros for use with 23 | # Happy Hare that use the parking logic defined in 'mmu_sequence.cfg' and are 24 | # centrally configured in 'mmu_macro_vars.cfg' 25 | # 26 | # (Technically you can also use your own set but you will likely need to 27 | # modify configuration to avoid double retraction, etc) 28 | # 29 | [gcode_macro PAUSE] 30 | rename_existing: BASE_PAUSE 31 | description: Pause the print and park 32 | gcode: 33 | {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} 34 | 35 | {% if printer.pause_resume.is_paused %} 36 | MMU_LOG MSG="Print is already paused" 37 | {% else %} 38 | _MMU_SAVE_POSITION 39 | BASE_PAUSE 40 | {% if not printer.mmu.enabled %} 41 | _MMU_PARK OPERATION="pause" 42 | {% endif %} 43 | {vars.user_pause_extension|default("")} 44 | {% endif %} 45 | 46 | [gcode_macro RESUME] 47 | rename_existing: BASE_RESUME 48 | description: Resume the print 49 | gcode: 50 | {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} 51 | 52 | {% if not printer.pause_resume.is_paused %} 53 | MMU_LOG MSG="Print is not paused. Resume ignored" 54 | {% else %} 55 | {vars.user_resume_extension|default("")} 56 | {% if not printer.mmu.enabled %} 57 | _MMU_RESTORE_POSITION # This will take the correct "over and down" movement path and unretract 58 | {% endif %} 59 | BASE_RESUME 60 | {% endif %} 61 | 62 | [gcode_macro CANCEL_PRINT] 63 | rename_existing: BASE_CANCEL_PRINT 64 | description: Cancel print 65 | gcode: 66 | {% set vars = printer['gcode_macro _MMU_CLIENT_VARS'] %} 67 | {% set reset_ttg_on_cancel = vars.reset_ttg_on_cancel|default('true')|lower == 'true' %} 68 | {% set unload_tool_on_cancel = vars.unload_tool_on_cancel|default('false')|lower == 'true' %} 69 | 70 | MMU_LOG MSG="Print cancelled!" 71 | {% if not printer.mmu.enabled %} 72 | _MMU_PARK OPERATION="cancel" 73 | {% else %} 74 | {% if unload_tool_on_cancel %} 75 | MMU_LOG MSG="Ejecting filament on print cancel" 76 | MMU_UNLOAD RESTORE=0 77 | {% endif %} 78 | {% if reset_ttg_on_cancel %} 79 | MMU_TTG_MAP RESET=1 QUIET=1 80 | {% endif %} 81 | {% endif %} 82 | _MMU_CLEAR_POSITION 83 | TURN_OFF_HEATERS 84 | M107 ; Fan off 85 | SET_PAUSE_NEXT_LAYER ENABLE=0 86 | SET_PAUSE_AT_LAYER ENABLE=0 LAYER=0 87 | {vars.user_cancel_extension|default("")} 88 | BASE_CANCEL_PRINT 89 | 90 | 91 | # The following macros are copied from the Mainsail client macros (mainsail.cfg) 92 | # They are integrated here to add the extra functionality into the Happy Hare 93 | # client_macros whilst still retaining centralized and consistent parking logic 94 | # 95 | # Copyright (C) 2022 Alex Zellner 96 | 97 | # Usage: SET_PAUSE_NEXT_LAYER [ENABLE=[0|1]] [MACRO=] 98 | [gcode_macro SET_PAUSE_NEXT_LAYER] 99 | description: Enable a pause if the next layer is reached 100 | gcode: 101 | {% set pause_next_layer = printer['gcode_macro SET_PRINT_STATS_INFO'].pause_next_layer %} 102 | {% set ENABLE = params.ENABLE|default(1)|int != 0 %} 103 | {% set MACRO = params.MACRO|default(pause_next_layer.call, True) %} 104 | SET_GCODE_VARIABLE MACRO=SET_PRINT_STATS_INFO VARIABLE=pause_next_layer VALUE="{{ 'enable': ENABLE, 'call': MACRO }}" 105 | 106 | # Usage: SET_PAUSE_AT_LAYER [ENABLE=[0|1]] [LAYER=] [MACRO=] 107 | [gcode_macro SET_PAUSE_AT_LAYER] 108 | description: Enable/disable a pause if a given layer number is reached 109 | gcode: 110 | {% set pause_at_layer = printer['gcode_macro SET_PRINT_STATS_INFO'].pause_at_layer %} 111 | {% set ENABLE = params.ENABLE|int != 0 if params.ENABLE is defined else params.LAYER is defined %} 112 | {% set LAYER = params.LAYER|default(pause_at_layer.layer)|int %} 113 | {% set MACRO = params.MACRO|default(pause_at_layer.call, True) %} 114 | SET_GCODE_VARIABLE MACRO=SET_PRINT_STATS_INFO VARIABLE=pause_at_layer VALUE="{{ 'enable': ENABLE, 'layer': LAYER, 'call': MACRO }}" 115 | 116 | # Usage: SET_PRINT_STATS_INFO [TOTAL_LAYER=] [CURRENT_LAYER=] 117 | [gcode_macro SET_PRINT_STATS_INFO] 118 | rename_existing: SET_PRINT_STATS_INFO_BASE 119 | description: Overwrite, to get pause_next_layer and pause_at_layer feature 120 | variable_pause_next_layer: { 'enable': False, 'call': "PAUSE" } 121 | variable_pause_at_layer : { 'enable': False, 'layer': 0, 'call': "PAUSE" } 122 | gcode: 123 | {% if pause_next_layer.enable %} 124 | MMU_LOG MSG='{"%s, forced by pause_next_layer" % pause_next_layer.call}' 125 | {pause_next_layer.call} ; execute the given gcode to pause, should be either M600 or PAUSE 126 | SET_PAUSE_NEXT_LAYER ENABLE=0 127 | {% elif pause_at_layer.enable and params.CURRENT_LAYER is defined and params.CURRENT_LAYER|int == pause_at_layer.layer %} 128 | MMU_LOG MSG='{"%s, forced by pause_at_layer [%d]" % (pause_at_layer.call, pause_at_layer.layer)}' 129 | {pause_at_layer.call} ; execute the given gcode to pause, should be either M600 or PAUSE 130 | SET_PAUSE_AT_LAYER ENABLE=0 131 | {% endif %} 132 | SET_PRINT_STATS_INFO_BASE {rawparams} 133 | -------------------------------------------------------------------------------- /config/optional/mmu_menu.cfg: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # Happy Hare MMU Software 3 | # Supporting macros 4 | # 5 | # THIS FILE IS READ ONLY 6 | # 7 | # Copyright (C) 2022 moggieuk#6538 (discord) moggieuk@hotmail.com 8 | # This file may be distributed under the terms of the GNU GPLv3 license. 9 | # 10 | # Goal: Happy Hare MMU MENU designed for LCD Mini12864 screen 11 | # 12 | # (\_/) 13 | # ( *,*) 14 | # (")_(") Happy Hare Ready 15 | # 16 | # 17 | [menu __main __MMU] 18 | enable: {printer.mmu.enabled} 19 | type: list 20 | name: MMU 21 | index: 6 22 | 23 | [menu __main __MMU __HOME] 24 | type: command 25 | name: Home MMU 26 | index: 1 27 | gcode: MMU_HOME 28 | 29 | [menu __main __MMU __SERVO_UP] 30 | type: command 31 | name: Servo up 32 | index: 2 33 | gcode: MMU_SERVO POS=up 34 | 35 | [menu __main __MMU __SERVO_MOVE] 36 | type: command 37 | name: Servo move 38 | index: 3 39 | gcode: MMU_SERVO POS=move 40 | 41 | [menu __main __MMU __SERVO_DOWN] 42 | type: command 43 | name: Servo down 44 | index: 4 45 | gcode: MMU_SERVO POS=down 46 | 47 | [menu __main __MMU __CHANGE_TOOL] 48 | type: input 49 | name: Change Tool: {'%2d' % (menu.input|int)} 50 | input: 0 51 | input_min: 0 52 | input_max: 8 53 | input_step: 1 54 | index: 5 55 | gcode: 56 | MMU_CHANGE_TOOL STANDALONE=1 TOOL={menu.input|int} 57 | 58 | [menu __main __MMU __SELECT_TOOL] 59 | type: input 60 | name: Select Tool: {'%2d' % (menu.input|int)} 61 | input: 0 62 | input_min: 0 63 | input_max: 8 64 | input_step: 1 65 | index: 6 66 | gcode: 67 | MMU_SELECT TOOL={menu.input|int} 68 | 69 | [menu __main __MMU __PRELOAD_TOOL] 70 | type: input 71 | name: Preload Tool: {'%1d' % (menu.input|int)} 72 | input: 0 73 | input_min: 0 74 | input_max: 8 75 | input_step: 1 76 | index: 7 77 | gcode: 78 | MMU_PRELOAD GATE={menu.input|int} 79 | 80 | [menu __main __MMU __EJECT] 81 | type: command 82 | name: Eject 83 | index: 8 84 | gcode: MMU_EJECT 85 | 86 | [menu __main __MMU __RECOVER] 87 | type: command 88 | name: Recover 89 | index: 9 90 | gcode: MMU_RECOVER 91 | 92 | [menu __main __MMU __SELECT_BYPASS] 93 | enable: {not printer.idle_timeout.state == "Printing"} 94 | type: command 95 | name: Select bypass 96 | index: 10 97 | gcode: MMU_SELECT_BYPASS 98 | 99 | [menu __main __MMU __LOAD_BYPASS] 100 | enable: {not printer.idle_timeout.state == "Printing" and printer.mmu.gate == -2} 101 | type: command 102 | name: Load bypass 103 | index: 11 104 | gcode: MMU_LOAD 105 | 106 | [menu __main __MMU __UNLOAD_BYPASS] 107 | enable: {not printer.idle_timeout.state == "Printing" and printer.mmu.gate == -2} 108 | type: command 109 | name: Unload bypass 110 | index: 13 111 | gcode: MMU_EJECT 112 | 113 | [menu __main __MMU __clogdetection] 114 | type: input 115 | name: Clog detect: {'%2d' % (menu.input|int)} 116 | input: 0 117 | input_min: 0 118 | input_max: 2 119 | input_step: 1 120 | index: 14 121 | gcode: 122 | MMU_TEST_CONFIG enable_clog_detection={menu.input|int} 123 | 124 | [menu __main __MMU __endlessspool] 125 | type: input 126 | name: Endl. spool: {'%2d' % (menu.input|int)} 127 | input: 0 128 | input_min: 0 129 | input_max: 1 130 | input_step: 1 131 | index: 15 132 | gcode: 133 | MMU_TEST_CONFIG enable_endless_spool={menu.input|int} 134 | 135 | [menu __main __MMU __STATUS] 136 | type: command 137 | name: Show Status 138 | index: 16 139 | gcode: MMU_STATUS 140 | 141 | [menu __main __MMU __MOTORS_OFF] 142 | type: command 143 | name: Motors off 144 | index: 17 145 | gcode: MMU_MOTORS_OFF 146 | 147 | -------------------------------------------------------------------------------- /extras/.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | max-line-length=400 3 | 4 | [MESSAGES CONTROL] 5 | 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 6 | 7 | [DESIGN] 8 | # Maximum number of boolean expressions in an if statement 9 | max-bool-expr=6 10 | 11 | [MASTER] 12 | ignore=mmu_test.py 13 | -------------------------------------------------------------------------------- /extras/mmu/__init__.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Main module wrapper 3 | # 4 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 5 | # moggieuk@hotmail.com 6 | # 7 | # Goal: Firmware to control any Klipper based MMU 8 | # 9 | # Note that code is written in a system to support Python v2 given the widespread use in 10 | # the klipper community. Hopefully this will change soon. 11 | # 12 | # (\_/) 13 | # ( *,*) 14 | # (")_(") Happy Hare Ready 15 | # 16 | # This file may be distributed under the terms of the GNU GPLv3 license. 17 | # 18 | from .mmu import Mmu 19 | 20 | def load_config(config): 21 | return Mmu(config) 22 | -------------------------------------------------------------------------------- /extras/mmu/mmu_logger.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Logging helpers 3 | # 4 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 5 | # moggieuk@hotmail.com 6 | # 7 | # (\_/) 8 | # ( *,*) 9 | # (")_(") Happy Hare Ready 10 | # 11 | # This file may be distributed under the terms of the GNU GPLv3 license. 12 | # 13 | import logging, logging.handlers, threading, os, queue, atexit 14 | 15 | class MmuLogger: 16 | def __init__(self, logfile_path): 17 | name = os.path.splitext(os.path.basename(logfile_path))[0] 18 | self.logger = logging.getLogger(name) 19 | 20 | self.queue_listener = None 21 | if not any(isinstance(h, QueueHandler) for h in self.logger.handlers): 22 | handler = logging.handlers.TimedRotatingFileHandler(logfile_path, when='midnight', backupCount=3) 23 | handler.setFormatter(MultiLineFormatter('%(asctime)s %(message)s', datefmt='%H:%M:%S')) 24 | self.queue_listener = QueueListener(handler) 25 | self.logger.addHandler(QueueHandler(self.queue_listener.bg_queue)) 26 | 27 | self.logger.setLevel(logging.INFO) 28 | self.logger.propagate = False 29 | atexit.register(self.shutdown) 30 | 31 | def log(self, message): 32 | self.logger.info(message) 33 | 34 | def shutdown(self): 35 | if self.queue_listener is not None: 36 | self.queue_listener.stop() 37 | 38 | # Poll log queue on background thread and log each message to logfile 39 | class QueueHandler(logging.Handler): 40 | def __init__(self, log_queue): 41 | super(QueueHandler, self).__init__() 42 | self.queue = log_queue 43 | 44 | def emit(self, record): 45 | try: 46 | self.queue.put_nowait(record) 47 | except Exception: 48 | self.handleError(record) 49 | 50 | class QueueListener: 51 | def __init__(self, handler): 52 | self.bg_queue = queue.Queue() 53 | self.handler = handler 54 | self.bg_thread = threading.Thread(target=self._bg_thread) 55 | self.bg_thread.daemon = True 56 | self.bg_thread.start() 57 | 58 | def _bg_thread(self): 59 | while True: 60 | record = self.bg_queue.get(True) 61 | if record is None: 62 | break 63 | self.handler.handle(record) 64 | 65 | def stop(self): 66 | self.bg_queue.put_nowait(None) 67 | self.bg_thread.join() 68 | 69 | # Class to improve formatting of multi-line messages 70 | class MultiLineFormatter(logging.Formatter): 71 | def format(self, record): 72 | indent = ' ' * 9 73 | formatted_message = super(MultiLineFormatter, self).format(record) 74 | if record.exc_text: 75 | # Don't modify exception stack traces 76 | return formatted_message 77 | return formatted_message.replace('\n', '\n' + indent) 78 | -------------------------------------------------------------------------------- /extras/mmu/mmu_sensor_manager.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Manager to centralize mmu_sensor operations 3 | # 4 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 5 | # moggieuk@hotmail.com 6 | # 7 | # (\_/) 8 | # ( *,*) 9 | # (")_(") Happy Hare Ready 10 | # 11 | # This file may be distributed under the terms of the GNU GPLv3 license. 12 | # 13 | import random, logging, math, re 14 | 15 | # Happy Hare imports 16 | from ..mmu_sensors import MmuRunoutHelper 17 | from .mmu_shared import MmuError 18 | 19 | class MmuSensorManager: 20 | def __init__(self, mmu): 21 | self.mmu = mmu 22 | self.all_sensors = {} # All sensors on mmu unit optionally with unit prefix and gate suffix 23 | self.sensors = {} # All (presence detection) sensors on active unit stripped of unit prefix 24 | self.viewable_sensors = {} # Sensors of all types for current gate/unit renamed with simple names 25 | 26 | # Assemble all possible switch sensors in desired display order 27 | sensor_names = [] 28 | sensor_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, i) for i in range(self.mmu.num_gates)]) 29 | sensor_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, i) for i in range(self.mmu.num_gates)]) 30 | sensor_names.extend([ 31 | self.mmu.SENSOR_GATE, 32 | self.mmu.SENSOR_TENSION, 33 | self.mmu.SENSOR_COMPRESSION 34 | ]) 35 | if self.mmu.mmu_machine.num_units > 1: 36 | for i in range(self.mmu.mmu_machine.num_units): 37 | sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_GATE, i)) 38 | sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_TENSION, i)) 39 | sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_COMPRESSION, i)) 40 | sensor_names.extend([ 41 | self.mmu.SENSOR_EXTRUDER_ENTRY, 42 | self.mmu.SENSOR_TOOLHEAD 43 | ]) 44 | for name in sensor_names: 45 | sensor_name = name if re.search(r'_(\d+)$', name) else "%s_sensor" % name # Must match mmu_sensors 46 | sensor = self.mmu.printer.lookup_object("filament_switch_sensor %s" % sensor_name, None) 47 | if sensor is not None and isinstance(sensor.runout_helper, MmuRunoutHelper): 48 | self.all_sensors[name] = sensor 49 | 50 | # Special case for "no bowden" (one unit) designs where mmu_gate is an alias for extruder sensor 51 | 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: 52 | self.all_sensors[self.mmu.SENSOR_GATE] = self.all_sensors[self.mmu.SENSOR_EXTRUDER_ENTRY] 53 | 54 | # Setup subset of filament sensors that are also used for homing (endstops) 55 | self.endstop_names = [] 56 | self.endstop_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, i) for i in range(self.mmu.num_gates)]) 57 | self.endstop_names.extend([ 58 | self.mmu.SENSOR_GATE, 59 | self.mmu.SENSOR_TENSION, 60 | self.mmu.SENSOR_COMPRESSION 61 | ]) 62 | if self.mmu.mmu_machine.num_units > 1: 63 | for i in range(self.mmu.mmu_machine.num_units): 64 | self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_GATE, i)) 65 | self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_COMPRESSION, i)) 66 | self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_TENSION, i)) 67 | self.endstop_names.extend([ 68 | self.mmu.SENSOR_EXTRUDER_ENTRY, 69 | self.mmu.SENSOR_TOOLHEAD 70 | ]) 71 | for name in self.endstop_names: 72 | sensor_name = name if re.search(r'_(\d+)$', name) else "%s_sensor" % name # Must match mmu_sensors 73 | sensor = self.mmu.printer.lookup_object("filament_switch_sensor %s" % sensor_name, None) 74 | if sensor is not None and isinstance(sensor.runout_helper, MmuRunoutHelper): 75 | # Add sensor pin as an extra endstop for gear rail 76 | sensor_pin = sensor.runout_helper.switch_pin 77 | ppins = self.mmu.printer.lookup_object('pins') 78 | pin_params = ppins.parse_pin(sensor_pin, True, True) 79 | share_name = "%s:%s" % (pin_params['chip_name'], pin_params['pin']) 80 | ppins.allow_multi_use_pin(share_name) 81 | mcu_endstop = self.mmu.gear_rail.add_extra_endstop(sensor_pin, name) 82 | 83 | # This ensures rapid stopping of extruder stepper when endstop is hit on synced homing 84 | # otherwise the extruder can continue to move a small (speed dependent) distance 85 | if self.mmu.homing_extruder and name == self.mmu.SENSOR_TOOLHEAD: 86 | mcu_endstop.add_stepper(self.mmu.mmu_extruder_stepper.stepper) 87 | else: 88 | logging.warning("MMU: Improper setup: Filament sensor %s is not defined in [mmu_sensors]" % name) 89 | 90 | # Reset the "viewable" sensors used in UI (unit must be updated first) 91 | def reset_active_gate(self, gate): 92 | sensor_name_map = { 93 | self.mmu.SENSOR_PRE_GATE_PREFIX: self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, gate), 94 | self.mmu.SENSOR_GEAR_PREFIX: self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, gate), 95 | self.mmu.SENSOR_GATE: self.get_mapped_endstop_name(self.mmu.SENSOR_GATE), 96 | self.mmu.SENSOR_COMPRESSION: self.get_mapped_endstop_name(self.mmu.SENSOR_COMPRESSION), 97 | self.mmu.SENSOR_TENSION: self.get_mapped_endstop_name(self.mmu.SENSOR_TENSION), 98 | self.mmu.SENSOR_EXTRUDER_ENTRY: self.mmu.SENSOR_EXTRUDER_ENTRY, 99 | self.mmu.SENSOR_TOOLHEAD: self.mmu.SENSOR_TOOLHEAD 100 | } 101 | self.viewable_sensors = { 102 | name: self.all_sensors.get(mapped_name) 103 | for name, mapped_name in sensor_name_map.items() 104 | if self.all_sensors.get(mapped_name) is not None 105 | } 106 | 107 | # Activate only sensors for current unit and rename for access 108 | def reset_active_unit(self, unit): 109 | self.sensors = {} 110 | for name, sensor in self.all_sensors.items(): 111 | if name.startswith("unit_"): 112 | if unit != self.mmu.UNIT_UNKNOWN and name.startswith("unit_" + str(unit)): 113 | self.sensors[re.sub(r'unit_\d+_', '', name)] = sensor 114 | sensor.runout_helper.enable_button_feedback(True) 115 | else: 116 | # Ensure any excluded sensor is completely deactivated 117 | sensor.runout_helper.enable_runout(False) 118 | sensor.runout_helper.enable_button_feedback(False) 119 | else: 120 | self.sensors[name] = sensor 121 | 122 | # Return dict of all sensor states (or None if sensor disabled) 123 | def get_all_sensors(self, inactive=False): 124 | result = {} 125 | for name, sensor in self.sensors.items() if not inactive else self.all_sensors.items(): 126 | result[name] = bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None 127 | return result 128 | 129 | def has_sensor(self, name): 130 | return self.sensors[name].runout_helper.sensor_enabled if name in self.sensors else False 131 | 132 | def has_gate_sensor(self, name, gate): 133 | 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 134 | 135 | def get_gate_sensor_name(self, name, gate): 136 | return "%s_%d" % (name, gate) # Must match mmu_sensors 137 | 138 | def get_unit_sensor_name(self, name, unit): 139 | return "unit_%d_%s" % (unit, name) # Must match mmu_sensors 140 | 141 | # Get unit or gate specific endstop if it exists 142 | # Take generic name and look for "_genericName" and "genericName_" 143 | def get_mapped_endstop_name(self, endstop_name): 144 | mapped_name = self.get_unit_sensor_name(endstop_name, self.mmu.unit_selected) 145 | if mapped_name in self.endstop_names: 146 | return mapped_name 147 | 148 | mapped_name = self.get_gate_sensor_name(endstop_name, self.mmu.gate_selected) 149 | if mapped_name in self.endstop_names: 150 | return mapped_name 151 | 152 | return endstop_name 153 | 154 | # Return sensor state or None if not installed 155 | def check_sensor(self, name): 156 | sensor = self.sensors.get(name, None) 157 | if sensor is not None and sensor.runout_helper.sensor_enabled: 158 | detected = bool(sensor.runout_helper.filament_present) 159 | self.mmu.log_trace("(%s sensor %s filament)" % (name, "detects" if detected else "does not detect")) 160 | return detected 161 | else: 162 | return None 163 | 164 | # Return per-gate sensor state or None if not installed 165 | def check_gate_sensor(self, name, gate): 166 | sensor_name = self.get_gate_sensor_name(name, gate) 167 | sensor = self.sensors.get(sensor_name, None) 168 | if sensor is not None and sensor.runout_helper.sensor_enabled: 169 | detected = bool(sensor.runout_helper.filament_present) 170 | self.mmu.log_trace("(%s sensor %s filament)" % (sensor_name, "detects" if detected else "does not detect")) 171 | return detected 172 | else: 173 | return None 174 | 175 | # Returns True if ALL sensors before position detect filament 176 | # None if NO sensors available (disambiguate from non-triggered sensor) 177 | # Can be used as a "filament continuity test" 178 | def check_all_sensors_before(self, pos, gate, loading=True): 179 | sensors = self._get_sensors_before(pos, gate, loading) 180 | if all(state is None for state in sensors.values()): 181 | return None 182 | return all(state is not False for state in sensors.values()) 183 | 184 | # Returns True if ANY sensor before position detects filament 185 | # None if NO sensors available (disambiguate from non-triggered sensor) 186 | # Can be used as a filament visibility test over a portion of the travel 187 | def check_any_sensors_before(self, pos, gate, loading=True): 188 | sensors = self._get_sensors_before(pos, gate, loading) 189 | if all(state is None for state in sensors.values()): 190 | return None 191 | return any(state is True for state in sensors.values()) 192 | 193 | # Returns True if ALL sensors after position detect filament 194 | # None if NO sensors available (disambiguate from non-triggered sensor) 195 | # Can be used as a "filament continuity test" 196 | def check_all_sensors_after(self, pos, gate, loading=True): 197 | sensors = self._get_sensors_after(pos, gate, loading) 198 | if all(state is None for state in sensors.values()): 199 | return None 200 | return all(state is not False for state in sensors.values()) 201 | 202 | # Returns True if ANY sensor after position detects filament 203 | # None if no sensors available (disambiguate from non-triggered sensor) 204 | # Can be used to validate position 205 | def check_any_sensors_after(self, pos, gate, loading=True): 206 | sensors = self._get_sensors_after(pos, gate, loading) 207 | if all(state is None for state in sensors.values()): 208 | return None 209 | return any(state is True for state in sensors.values()) 210 | 211 | # Returns True is any sensors in current filament path are triggered (EXCLUDES pre-gate) 212 | # None if no sensors available (disambiguate from non-triggered sensor) 213 | def check_any_sensors_in_path(self): 214 | sensors = self._get_all_sensors_for_gate(self.mmu.gate_selected) 215 | if all(state is None for state in sensors.values()): 216 | return None 217 | return any(state is True for state in sensors.values()) 218 | 219 | # Returns True is any sensors in filament path are not triggered 220 | # None if no sensors available (disambiguate from non-triggered sensor) 221 | # Can be used to spot failure in "continuity" i.e. runout 222 | def check_for_runout(self): 223 | sensors = self._get_sensors_before(self.mmu.FILAMENT_POS_LOADED, self.mmu.gate_selected) 224 | if all(state is None for state in sensors.values()): 225 | return None 226 | return any(state is False for state in sensors.values()) 227 | 228 | # Error with explanation if any filament sensors don't detect filament 229 | def confirm_loaded(self): 230 | sensors = self._get_sensors_before(self.mmu.FILAMENT_POS_LOADED, self.mmu.gate_selected) 231 | if any(state is False for state in sensors.values()): 232 | MmuError("Loaded check failed:\nFilament not detected by sensors: %s" % ', '.join([name for name, state in sensors.items() if state is False])) 233 | 234 | # Return formatted summary of all sensors under management (include all mmu units) 235 | def get_sensor_summary(self, detail=False): 236 | summary = "" 237 | for name, state in self.get_all_sensors(inactive=True).items(): 238 | if state is not None or detail: 239 | sensor = self.all_sensors.get(name) 240 | trig = "%s" % 'TRIGGERED' if sensor.runout_helper.filament_present else 'Open' 241 | summary += "%s: %s" % (name, ("(%s, currently disabled)" % trig) if state is None else trig) 242 | if detail and sensor.runout_helper.runout_suspended is not None and state is not None: 243 | summary += "%s" % (", Runout enabled" if not sensor.runout_helper.runout_suspended else "") 244 | summary += "\n" 245 | return summary 246 | 247 | def enable_runout(self, gate): 248 | self._set_sensor_runout(True, gate) 249 | 250 | def disable_runout(self, gate): 251 | self._set_sensor_runout(False, gate) 252 | 253 | def _set_sensor_runout(self, enable, gate): 254 | for name, sensor in self.sensors.items(): 255 | if isinstance(sensor.runout_helper, MmuRunoutHelper): 256 | per_gate = re.search(r'_(\d+)$', name) # Must match mmu_sensors 257 | if per_gate: 258 | sensor.runout_helper.enable_runout(enable and (int(per_gate.group(1)) == gate)) 259 | else: 260 | sensor.runout_helper.enable_runout(enable and (gate != self.mmu.TOOL_GATE_UNKNOWN)) 261 | 262 | # Defines sensors and relationship to filament_pos state for easy filament tracing 263 | def _get_sensors(self, pos, gate, position_condition): 264 | result = {} 265 | if gate >= 0: 266 | sensor_selection = [ 267 | (self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, gate), None), 268 | (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 else None), 269 | (self.mmu.SENSOR_GATE, self.mmu.FILAMENT_POS_HOMED_GATE), 270 | (self.mmu.SENSOR_EXTRUDER_ENTRY, self.mmu.FILAMENT_POS_HOMED_ENTRY), 271 | (self.mmu.SENSOR_TOOLHEAD, self.mmu.FILAMENT_POS_HOMED_TS), 272 | ] 273 | for name, position_check in sensor_selection: 274 | sensor = self.sensors.get(name, None) 275 | if sensor and position_condition(pos, position_check): 276 | result[name] = bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None 277 | return result 278 | 279 | def _get_sensors_before(self, pos, gate, loading=True): 280 | return self._get_sensors(pos, gate, lambda p, pc: pc is None or (loading and p >= pc) or (not loading and p > pc)) 281 | 282 | def _get_sensors_after(self, pos, gate, loading=True): 283 | return self._get_sensors(pos, gate, lambda p, pc: pc is not None and ((loading and p < pc) or (not loading and p <= pc))) 284 | 285 | def _get_all_sensors_for_gate(self, gate): 286 | return self._get_sensors(-1, gate, lambda p, pc: pc is not None) 287 | 288 | def get_status(self): 289 | result = { 290 | name: bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None 291 | for name, sensor in self.viewable_sensors.items() 292 | } 293 | return result 294 | -------------------------------------------------------------------------------- /extras/mmu/mmu_shared.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Shared classes 3 | # 4 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 5 | # moggieuk@hotmail.com 6 | # 7 | # 8 | # (\_/) 9 | # ( *,*) 10 | # (")_(") Happy Hare Ready 11 | # 12 | # This file may be distributed under the terms of the GNU GPLv3 license. 13 | # 14 | import sys 15 | 16 | # Default to use unicode on Python2. Not worth the hassle until klipper drops py2 support! 17 | UI_SPACE, UI_SEPARATOR, UI_DASH, UI_DEGREE, UI_BLOCK, UI_CASCADE = ' ', '.', '-', '^', '*', '-' 18 | UI_BOX_TL, UI_BOX_BL, UI_BOX_TR, UI_BOX_BR = '+', '+', '+', '+' 19 | UI_BOX_L, UI_BOX_R, UI_BOX_T, UI_BOX_B = '+', '+', '+', '+' 20 | UI_BOX_M, UI_BOX_H, UI_BOX_V = '+', '-', '|' 21 | UI_EMOTICONS = ['?', 'A+', 'A', 'B', 'C', 'C-', 'D', 'F'] 22 | UI_SQUARE, UI_CUBE = '^2', '^3' 23 | 24 | 25 | if sys.version_info[0] >= 3: 26 | # Use (common) unicode for improved formatting and klipper layout 27 | UI_SPACE, UI_SEPARATOR, UI_DASH, UI_DEGREE, UI_BLOCK, UI_CASCADE = '\u00A0', '\u00A0', '\u2014', '\u00B0', '\u2588', '\u2514' 28 | # Not all character sets include these so best to use defaults above 29 | # UI_BOX_TL, UI_BOX_BL, UI_BOX_TR, UI_BOX_BR = '\u250C', '\u2514', '\u2510', '\u2518' 30 | # UI_BOX_L, UI_BOX_R, UI_BOX_T, UI_BOX_B = '\u251C', '\u2524', '\u252C', '\u2534' 31 | # UI_BOX_M, UI_BOX_H, UI_BOX_V = '\u253C', '\u2500', '\u2502' 32 | UI_EMOTICONS = [UI_DASH, '\U0001F60E', '\U0001F603', '\U0001F60A', '\U0001F610', '\U0001F61F', '\U0001F622', '\U0001F631'] 33 | UI_SQUARE, UI_CUBE = '\u00B2', '\u00B3' 34 | 35 | 36 | # Mmu exception error class 37 | class MmuError(Exception): 38 | pass 39 | -------------------------------------------------------------------------------- /extras/mmu/mmu_utils.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Utility classes for Happy Hare MMU 3 | # 4 | # DebugStepperMovement 5 | # Goal: Internal testing class for debugging synced movement 6 | # 7 | # PurgeVolCalculator 8 | # Goal: Purge volume calculator based on color change 9 | # 10 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 11 | # moggieuk@hotmail.com 12 | # 13 | # (\_/) 14 | # ( *,*) 15 | # (")_(") Happy Hare Ready 16 | # 17 | # This file may be distributed under the terms of the GNU GPLv3 license. 18 | # 19 | import math 20 | 21 | 22 | # Internal testing class for debugging synced movement 23 | # Add this around move logic: 24 | # with DebugStepperMovement(self): 25 | # 26 | class DebugStepperMovement: 27 | def __init__(self, mmu, debug=False): 28 | self.mmu = mmu 29 | self.debug = debug 30 | 31 | def __enter__(self): 32 | if self.debug: 33 | self.g_steps0 = self.mmu.gear_rail.steppers[0].get_mcu_position() 34 | self.g_pos0 = self.mmu.gear_rail.steppers[0].get_commanded_position() 35 | self.e_steps0 = self.mmu.mmu_extruder_stepper.stepper.get_mcu_position() 36 | self.e_pos0 = self.mmu.mmu_extruder_stepper.stepper.get_commanded_position() 37 | self.rail_pos0 = self.mmu.mmu_toolhead.get_position()[1] 38 | 39 | def __exit__(self, exc_type, exc_val, exc_tb): 40 | if self.debug: 41 | self.mmu.log_always("Waiting for movement to complete...") 42 | self.mmu.movequeues_wait() 43 | g_steps1 = self.mmu.gear_rail.steppers[0].get_mcu_position() 44 | g_pos1 = self.mmu.gear_rail.steppers[0].get_commanded_position() 45 | e_steps1 = self.mmu.mmu_extruder_stepper.stepper.get_mcu_position() 46 | e_pos1 = self.mmu.mmu_extruder_stepper.stepper.get_commanded_position() 47 | rail_pos1 = self.mmu.mmu_toolhead.get_position()[1] 48 | 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)) 49 | 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)) 50 | self.mmu.log_always("Rail movement: %.4fmm" % (rail_pos1 - self.rail_pos0)) 51 | 52 | 53 | class PurgeVolCalculator: 54 | def __init__(self, min_purge_vol, max_purge_vol, multiplier): 55 | self.min_purge_vol = min_purge_vol 56 | self.max_purge_vol = max_purge_vol 57 | self.multiplier = multiplier 58 | 59 | def calc_purge_vol_by_rgb(self, src_r, src_g, src_b, dst_r, dst_g, dst_b): 60 | src_r_f = float(src_r) / 255.0 61 | src_g_f = float(src_g) / 255.0 62 | src_b_f = float(src_b) / 255.0 63 | dst_r_f = float(dst_r) / 255.0 64 | dst_g_f = float(dst_g) / 255.0 65 | dst_b_f = float(dst_b) / 255.0 66 | 67 | from_hsv_h, from_hsv_s, from_hsv_v = self.RGB2HSV(src_r_f, src_g_f, src_b_f) 68 | to_hsv_h, to_hsv_s, to_hsv_v = self.RGB2HSV(dst_r_f, dst_g_f, dst_b_f) 69 | hs_dist = self.DeltaHS_BBS(from_hsv_h, from_hsv_s, from_hsv_v, to_hsv_h, to_hsv_s, to_hsv_v) 70 | from_lumi = self.get_luminance(src_r_f, src_g_f, src_b_f) 71 | to_lumi = self.get_luminance(dst_r_f, dst_g_f, dst_b_f) 72 | 73 | lumi_purge = 0.0 74 | if to_lumi >= from_lumi: 75 | lumi_purge = math.pow(to_lumi - from_lumi, 0.7) * 560.0 76 | else: 77 | lumi_purge = (from_lumi - to_lumi) * 80.0 78 | 79 | inter_hsv_v = 0.67 * to_hsv_v + 0.33 * from_hsv_v 80 | hs_dist = min(inter_hsv_v, hs_dist) 81 | hs_purge = 230.0 * hs_dist 82 | 83 | purge_volume = self.calc_triangle_3rd_edge(hs_purge, lumi_purge, 120.0) 84 | purge_volume = max(purge_volume, 0.0) 85 | purge_volume += self.min_purge_vol 86 | purge_volume *= self.multiplier 87 | purge_volume = min(int(purge_volume), self.max_purge_vol) 88 | 89 | return purge_volume 90 | 91 | def calc_purge_vol_by_hex(self, src_clr, dst_clr): 92 | src_rgb = self.hex_to_rgb(src_clr) 93 | dst_rgb = self.hex_to_rgb(dst_clr) 94 | return self.calc_purge_vol_by_rgb(*(src_rgb + dst_rgb)) 95 | 96 | @staticmethod 97 | def RGB2HSV(r, g, b): 98 | Cmax = max(r, g, b) 99 | Cmin = min(r, g, b) 100 | delta = Cmax - Cmin 101 | 102 | if abs(delta) < 0.001: 103 | h = 0.0 104 | elif Cmax == r: 105 | h = 60.0 * math.fmod((g - b) / delta, 6.0) 106 | elif Cmax == g: 107 | h = 60.0 * ((b - r) / delta + 2) 108 | else: 109 | h = 60.0 * ((r - g) / delta + 4) 110 | s = 0.0 if abs(Cmax) < 0.001 else delta / Cmax 111 | v = Cmax 112 | return h, s, v 113 | 114 | @staticmethod 115 | def to_radians(degree): 116 | return degree / 180.0 * math.pi 117 | 118 | @staticmethod 119 | def get_luminance(r, g, b): 120 | return r * 0.3 + g * 0.59 + b * 0.11 121 | 122 | @staticmethod 123 | def calc_triangle_3rd_edge(edge_a, edge_b, degree_ab): 124 | return math.sqrt(edge_a * edge_a + edge_b * edge_b - 2 * edge_a * edge_b * math.cos(PurgeVolCalculator.to_radians(degree_ab))) 125 | 126 | @staticmethod 127 | def DeltaHS_BBS(h1, s1, v1, h2, s2, v2): 128 | h1_rad = PurgeVolCalculator.to_radians(h1) 129 | h2_rad = PurgeVolCalculator.to_radians(h2) 130 | 131 | dx = math.cos(h1_rad) * s1 * v1 - math.cos(h2_rad) * s2 * v2 132 | dy = math.sin(h1_rad) * s1 * v1 - math.sin(h2_rad) * s2 * v2 133 | dxy = math.sqrt(dx * dx + dy * dy) 134 | 135 | return min(1.2, dxy) 136 | 137 | @staticmethod 138 | def hex_to_rgb(hex_color): 139 | hex_color = hex_color.lstrip('#') 140 | if len(hex_color) == 3: 141 | hex_color = ''.join([c * 2 for c in hex_color]) 142 | if len(hex_color) == 8: 143 | hex_color = hex_color[:6] 144 | if len(hex_color) != 6: 145 | raise ValueError("Invalid hex color code, it should be 3, 6 or 8 digits long") 146 | color_value = int(hex_color, 16) 147 | r = (color_value >> 16) & 0xFF 148 | g = (color_value >> 8) & 0xFF 149 | b = color_value & 0xFF 150 | return r, g, b 151 | -------------------------------------------------------------------------------- /extras/mmu_encoder.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Driver for encoder that supports movement measurement, runout/clog detection and flow rate calc 3 | # 4 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 5 | # moggieuk@hotmail.com 6 | # 7 | # Based on: 8 | # Original Enraged Rabbit Carrot Feeder Project Copyright (C) 2021 Ette 9 | # Generic Filament Sensor Module Copyright (C) 2019 Eric Callahan 10 | # Filament Motion Sensor Module Copyright (C) 2021 Joshua Wherrett 11 | # 12 | # (\_/) 13 | # ( *,*) 14 | # (")_(") Happy Hare Ready 15 | # 16 | # This file may be distributed under the terms of the GNU GPLv3 license. 17 | # 18 | import logging, time 19 | 20 | # Klipper imports 21 | from . import pulse_counter 22 | 23 | class MmuEncoder: 24 | CHECK_MOVEMENT_TIMEOUT = 0.250 25 | 26 | RUNOUT_DISABLED = 0 27 | RUNOUT_STATIC = 1 28 | RUNOUT_AUTOMATIC = 2 29 | 30 | def __init__(self, config): 31 | self.name = config.get_name().split()[-1] 32 | self.printer = config.get_printer() 33 | self.reactor = self.printer.get_reactor() 34 | self.gcode = self.printer.lookup_object('gcode') 35 | encoder_pin = config.get('encoder_pin') 36 | self._logger = None 37 | 38 | # For counter functionality 39 | self.sample_time = config.getfloat('sample_time', 0.1, above=0.) 40 | self.poll_time = config.getfloat('poll_time', 0.001, above=0.) 41 | self.set_resolution(config.getfloat('encoder_resolution', 1., above=0.)) # Must be calibrated by user in Happy Hare 42 | self._last_time = None 43 | self._counts = self._last_count = 0 44 | self._counter = pulse_counter.MCU_counter(self.printer, encoder_pin, self.sample_time, self.poll_time) 45 | self._counter.setup_callback(self._counter_callback) 46 | self._movement = False 47 | 48 | # For clog/runout functionality 49 | self.extruder_name = config.get('extruder', 'extruder') 50 | # The runout headroom that MMU will attempt to maintain (closest MMU comes to triggering runout) 51 | self.desired_headroom = config.getfloat('desired_headroom', 6., above=0.) 52 | # The "damping" effect of last measurement. Higher value means clog_length will be reduced more slowly 53 | self.average_samples = config.getint('average_samples', 4, minval=1) 54 | # The extrusion interval where new detection_length is calculated (also done on toolchange) 55 | self.next_calibration_point = self.calibration_length = config.getfloat('calibration_length', 10000., minval=50.) # 10m 56 | # Detection length will be set by MMU calibration 57 | self.detection_length = self.min_headroom = config.getfloat('detection_length', 10., above=2.) 58 | self.event_delay = config.getfloat('event_delay', 2., above=0.) 59 | self.pause_delay = config.getfloat('pause_delay', 0, above=0.) 60 | self.runout_gcode = '__MMU_ENCODER_RUNOUT' 61 | self.insert_gcode = '__MMU_ENCODER_INSERT' 62 | self._enabled = True # Runout/Clog functionality 63 | self.min_event_systime = self.reactor.NEVER 64 | self.extruder = self.estimated_print_time = None 65 | self.filament_detected = False 66 | self.detection_mode = self.RUNOUT_STATIC 67 | self.last_extruder_pos = self.filament_runout_pos = 0. 68 | 69 | # For flowrate functionality 70 | self.flowrate_last_encoder_pos = 0. 71 | self.extrusion_flowrate = 0. 72 | self.samples = [] 73 | self.flowrate_samples = config.getint('flowrate_samples', 20, minval=5) 74 | 75 | # Register event handlers 76 | self.printer.register_event_handler('klippy:ready', self._handle_ready) 77 | self.printer.register_event_handler('klippy:connect', self._handle_connect) 78 | self.printer.register_event_handler('idle_timeout:printing', self._handle_printing) 79 | self.printer.register_event_handler('idle_timeout:ready', self._handle_not_printing) 80 | self.printer.register_event_handler('idle_timeout:idle', self._handle_not_printing) 81 | 82 | def _handle_connect(self): 83 | try: 84 | self.extruder = self.printer.lookup_object(self.extruder_name) 85 | except Exception: 86 | pass # Can set this later 87 | self.last_extruder_pos = 0. 88 | self.filament_runout_pos = self.min_headroom = self.detection_length 89 | 90 | def _handle_ready(self): 91 | self.min_event_systime = self.reactor.monotonic() + 2. # Don't process events too early 92 | self.estimated_print_time = self.printer.lookup_object('mcu').estimated_print_time 93 | self._reset_filament_runout_params() 94 | self._extruder_pos_update_timer = self.reactor.register_timer(self._extruder_pos_update_event) 95 | 96 | def _handle_printing(self, print_time): 97 | self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NOW) # Enabled 98 | 99 | def _handle_not_printing(self, print_time): 100 | self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NEVER) # Disabled 101 | 102 | def _get_extruder_pos(self, eventtime=None): 103 | if eventtime is None: 104 | eventtime = self.reactor.monotonic() 105 | print_time = self.estimated_print_time(eventtime) 106 | if self.extruder: 107 | return self.extruder.find_past_position(print_time) 108 | else: 109 | return 0. 110 | 111 | # Called periodically to check filament movement 112 | def _extruder_pos_update_event(self, eventtime): 113 | if self._enabled: 114 | extruder_pos = self._get_extruder_pos(eventtime) 115 | 116 | # First lets see if we got encoder movement since last invocation 117 | if self._movement: 118 | self._movement = False 119 | self.filament_runout_pos = max(extruder_pos + self.detection_length, self.filament_runout_pos) 120 | 121 | if extruder_pos >= self.next_calibration_point: 122 | if self.next_calibration_point > 0: 123 | self._update_detection_length() 124 | self.next_calibration_point = extruder_pos + self.calibration_length 125 | if self.filament_runout_pos - extruder_pos < self.min_headroom: 126 | self.min_headroom = self.filament_runout_pos - extruder_pos 127 | if self._logger and self.min_headroom < self.desired_headroom: 128 | if self.detection_mode == self.RUNOUT_AUTOMATIC: 129 | self._logger("Automatic clog detection: new min_headroom (< %.1fmm desired): %.1fmm" % (self.desired_headroom, self.min_headroom)) 130 | elif self.detection_mode == self.RUNOUT_STATIC: 131 | self._logger("Warning: Only %.1fmm of headroom to clog/runout" % self.min_headroom) 132 | self._handle_filament_event(extruder_pos < self.filament_runout_pos) 133 | 134 | # Flowrate calc. Depends of calibration accuracy of encoder 135 | encoder_pos = self.get_distance() 136 | # If encoder has moved, record the extruder and encoder movement for flow rate calcs 137 | if encoder_pos > self.flowrate_last_encoder_pos: 138 | self._record(encoder_pos, extruder_pos) 139 | self.flowrate_last_encoder_pos = encoder_pos 140 | 141 | self.last_extruder_pos = extruder_pos 142 | return eventtime + self.CHECK_MOVEMENT_TIMEOUT 143 | 144 | def _reset_filament_runout_params(self, eventtime=None): 145 | if eventtime is None: 146 | eventtime = self.reactor.monotonic() 147 | self.last_extruder_pos = self._get_extruder_pos(eventtime) 148 | self.flowrate_last_encoder_pos = self.get_distance() 149 | self.extrusion_flowrate = 0. 150 | self.samples = [] 151 | self.filament_runout_pos = self.last_extruder_pos + self.detection_length + self.desired_headroom # Add headroom to decrease sensitivity on startup 152 | self.next_calibration_point = self.last_extruder_pos + self.calibration_length 153 | self.min_headroom = self.detection_length 154 | 155 | # Called periodically to tune the clog detection length 156 | def _update_detection_length(self, increase_only=False): 157 | if not self._enabled: return 158 | if self.detection_mode != self.RUNOUT_AUTOMATIC: 159 | return 160 | current_detection_length = self.detection_length 161 | if self.min_headroom < self.desired_headroom: 162 | # Maintain headroom 163 | extra_length = min((self.desired_headroom - self.min_headroom), self.desired_headroom) 164 | self.detection_length += extra_length 165 | if self._logger: 166 | self._logger("Automatic clog detection: maintaining headroom by adding %.1fmm to detection_length" % extra_length) 167 | elif not increase_only: 168 | # Average down 169 | sample = self.detection_length - (self.min_headroom - self.desired_headroom) 170 | self.detection_length = ((self.average_samples * self.detection_length) + self.desired_headroom - self.min_headroom) / self.average_samples 171 | if self._logger: 172 | self._logger("Automatic clog detection: averaging down detection_length with new %.1fmm measurement" % sample) 173 | else: 174 | return 175 | 176 | self.min_headroom = self.detection_length 177 | self.filament_runout_pos = self.last_extruder_pos + self.detection_length 178 | if round(self.detection_length, 1) != round(current_detection_length, 1): # Persist if significant 179 | if self._logger: 180 | self._logger("Automatic clog detection: reset detection_length to %.1fmm" % self.min_headroom) 181 | self.set_clog_detection_length(self.detection_length) 182 | 183 | # Called to see if state update requires callback notification 184 | def _handle_filament_event(self, filament_detected): 185 | if self.filament_detected == filament_detected: 186 | return 187 | self.filament_detected = filament_detected 188 | eventtime = self.reactor.monotonic() 189 | if eventtime < self.min_event_systime or self.detection_mode == self.RUNOUT_DISABLED or not self._enabled: 190 | return 191 | is_printing = self.printer.lookup_object("idle_timeout").get_status(eventtime)["state"] == "Printing" 192 | if filament_detected: 193 | if not is_printing and self.insert_gcode is not None: 194 | # Insert detected 195 | self.min_event_systime = self.reactor.NEVER 196 | logging.info("MMU: Encoder Sensor %s: insert event detected, Time %.2f" % (self.name, eventtime)) 197 | self.reactor.register_callback(self._insert_event_handler) 198 | else: 199 | if is_printing and self.runout_gcode is not None: 200 | # Runout detected 201 | self.min_event_systime = self.reactor.NEVER 202 | logging.info("MMU: Encoder Sensor %s: runout event detected, Time %.2f" % (self.name, eventtime)) 203 | self.reactor.register_callback(self._runout_event_handler) 204 | 205 | def _runout_event_handler(self, eventtime): 206 | # Pausing from inside an event requires that the pause portion of pause_resume execute immediately. 207 | pause_resume = self.printer.lookup_object('pause_resume') 208 | pause_resume.send_pause_command() 209 | if self.pause_delay: 210 | self.printer.get_reactor().pause(eventtime + self.pause_delay) 211 | self._exec_gcode(self.runout_gcode) 212 | 213 | def _insert_event_handler(self, eventtime): 214 | self._exec_gcode(self.insert_gcode) 215 | 216 | def _exec_gcode(self, command): 217 | try: 218 | self.gcode.run_script(command) 219 | except Exception: 220 | logging.exception("MMU: Error running mmu encoder handler: `%s`" % command) 221 | self.min_event_systime = self.reactor.monotonic() + self.event_delay 222 | 223 | def get_clog_detection_length(self): 224 | return self.detection_length 225 | 226 | def set_clog_detection_length(self, clog_length): 227 | clog_length = max(clog_length, 2.) 228 | self.detection_length = clog_length 229 | self._reset_filament_runout_params() 230 | 231 | def update_clog_detection_length(self): 232 | self._update_detection_length() 233 | 234 | def set_mode(self, mode): 235 | if self.RUNOUT_DISABLED <= mode <= self.RUNOUT_AUTOMATIC: 236 | self.detection_mode = mode 237 | 238 | def set_extruder(self, extruder_name): 239 | self.extruder = self.printer.lookup_object(extruder_name) 240 | if not self.extruder: 241 | raise self.printer.config.error("Extruder named `%s` not found" % extruder_name) 242 | self.extruder_name = extruder_name 243 | self.filament_runout_pos = self.min_headroom = self.detection_length 244 | 245 | def set_logger(self, log): 246 | self._logger = log 247 | 248 | def enable(self): 249 | self._reset_filament_runout_params() 250 | self._enabled = True 251 | 252 | def disable(self): 253 | self._enabled = False 254 | 255 | def is_enabled(self): 256 | return self._enabled 257 | 258 | def _record(self, encoder_pos, extruder_pos): 259 | self.samples.append((encoder_pos, extruder_pos)) 260 | if len(self.samples) > self.flowrate_samples: 261 | self.samples = self.samples[-self.flowrate_samples:] 262 | encoder_movement = encoder_pos - self.samples[0][0] 263 | extruder_movement = extruder_pos - self.samples[0][1] 264 | new_extrusion_flowrate = (encoder_movement / extruder_movement) if extruder_movement > 0. else 1. 265 | self.extrusion_flowrate = (self.extrusion_flowrate + new_extrusion_flowrate) / 2. 266 | 267 | # Callback for MCU_counter 268 | def _counter_callback(self, print_time, count, count_time): 269 | if self._last_time is None: # First sample 270 | self._last_time = print_time 271 | elif count_time > self._last_time: 272 | self._last_time = count_time 273 | new_counts = count - self._last_count 274 | self._counts += new_counts 275 | self._movement = new_counts > 0 276 | else: # No counts since last sample 277 | self._last_time = print_time 278 | self._last_count = count 279 | 280 | def set_resolution(self, resolution): 281 | self.resolution = resolution 282 | 283 | def get_resolution(self): 284 | return self.resolution 285 | 286 | def get_counts(self): 287 | return self._counts 288 | 289 | def get_distance(self): 290 | return self._counts * self.resolution 291 | 292 | def set_distance(self, new_distance): 293 | self._counts = int(round(new_distance / self.resolution)) 294 | 295 | def reset_counts(self): 296 | self._counts = 0 297 | 298 | def get_status(self, eventtime): 299 | return { 300 | 'encoder_pos': round(self.get_distance(), 1), 301 | 'detection_length': round(self.detection_length, 1), 302 | 'min_headroom': round(self.min_headroom, 1), 303 | 'headroom': round(self.filament_runout_pos - self.last_extruder_pos, 1), 304 | 'desired_headroom': round(self.desired_headroom, 1), 305 | 'detection_mode': self.detection_mode, 306 | 'enabled': self._enabled, 307 | 'flow_rate': int(round(min(self.extrusion_flowrate, 1.) * 100)) 308 | } 309 | 310 | def load_config_prefix(config): 311 | return MmuEncoder(config) 312 | -------------------------------------------------------------------------------- /extras/mmu_espooler.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # 3 | # Implements h/w "eSpooler" control for a MMU unit that is powered by a DC motor 4 | # (normally PWM speed controlled) that can be used to rewind a filament spool or be 5 | # driven peridically in the forward direction to provide "forward assist" functionality. 6 | # For simplicity of setup it is assumed that all pins are of the same type/config per mmu_unit. 7 | # Control is via direct control or klipper events. 8 | # 9 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 10 | # moggieuk@hotmail.com 11 | # 12 | # (\_/) 13 | # ( *,*) 14 | # (")_(") Happy Hare Ready 15 | # 16 | # This file may be distributed under the terms of the GNU GPLv3 license. 17 | # 18 | import logging, time 19 | 20 | from . import output_pin 21 | 22 | MAX_SCHEDULE_TIME = 5.0 23 | 24 | class MmuESpooler: 25 | 26 | def __init__(self, config, first_gate=0, num_gates=23): 27 | self.config = config 28 | self.first_gate = first_gate 29 | self.num_gates = num_gates 30 | self.name = config.get_name().split()[-1] 31 | self.printer = config.get_printer() 32 | self.reactor = self.printer.get_reactor() 33 | self.respool_gates = [] 34 | self.assist_gates = [] 35 | self.mmu = None 36 | self.burst_trigger_enabled = {} 37 | self.burst_trigger_state = {} 38 | self.back_to_back_burst_count = {} 39 | self.burst_gates = set() # Gates with burst assist in operation 40 | 41 | # Get config 42 | self.motor_mcu_pins = {} 43 | self.last_value = {} 44 | self.operation = {} 45 | ppins = self.printer.lookup_object('pins') 46 | buttons = self.printer.load_object(config, 'buttons') 47 | 48 | # These params are assumed to be shared accross the espooler unit 49 | self.is_pwm = config.getboolean("pwm", True) 50 | self.hardware_pwm = config.getboolean("hardware_pwm", False) 51 | self.scale = config.getfloat('scale', 1., above=0.) 52 | self.cycle_time = config.getfloat("cycle_time", 0.100, above=0., maxval=MAX_SCHEDULE_TIME) 53 | self.shutdown_value = config.getfloat('shutdown_value', 0., minval=0., maxval=self.scale) / self.scale 54 | start_value = config.getfloat('value', 0., minval=0., maxval=self.scale) / self.scale 55 | 56 | for gate in range(self.first_gate, self.first_gate + self.num_gates + 1): 57 | self.respool_motor_pin = config.get('respool_motor_pin_%d' % gate, None) 58 | self.assist_motor_pin = config.get('assist_motor_pin_%d' % gate, None) 59 | self.enable_motor_pin = config.get('enable_motor_pin_%d' % gate, None) 60 | self.assist_trigger_pin = config.get('assist_trigger_pin_%d' % gate, None) 61 | 62 | # Setup pins 63 | if self.respool_motor_pin and not self._is_empty_pin(self.respool_motor_pin): 64 | if self.is_pwm: 65 | mcu_pin = ppins.setup_pin("pwm", self.respool_motor_pin) 66 | mcu_pin.setup_cycle_time(self.cycle_time, self.hardware_pwm) 67 | else: 68 | mcu_pin = ppins.setup_pin("digital_out", self.respool_motor_pin) 69 | 70 | name = "respool_%d" % gate 71 | mcu_pin.setup_max_duration(0.) 72 | mcu_pin.setup_start_value(start_value, self.shutdown_value) 73 | self.motor_mcu_pins[name] = mcu_pin 74 | self.last_value[name] = start_value 75 | self.respool_gates.append(gate) 76 | 77 | if self.assist_motor_pin and not self._is_empty_pin(self.assist_motor_pin): 78 | if self.is_pwm: 79 | mcu_pin = ppins.setup_pin("pwm", self.assist_motor_pin) 80 | mcu_pin.setup_cycle_time(self.cycle_time, self.hardware_pwm) 81 | else: 82 | mcu_pin = ppins.setup_pin("digital_out", self.assist_motor_pin) 83 | 84 | name = "assist_%d" % gate 85 | mcu_pin.setup_max_duration(0.) 86 | mcu_pin.setup_start_value(start_value, self.shutdown_value) 87 | self.motor_mcu_pins[name] = mcu_pin 88 | self.last_value[name] = start_value 89 | self.assist_gates.append(gate) 90 | 91 | if self.enable_motor_pin and not self._is_empty_pin(self.enable_motor_pin): 92 | mcu_pin = ppins.setup_pin("digital_out", self.enable_motor_pin) 93 | 94 | name = "enable_%d" % gate 95 | mcu_pin.setup_max_duration(0.) 96 | mcu_pin.setup_start_value(self.last_value, self.shutdown_value) 97 | self.motor_mcu_pins[name] = mcu_pin 98 | self.last_value[name] = start_value 99 | 100 | if self.assist_trigger_pin and not self._is_empty_pin(self.assist_trigger_pin): 101 | buttons.register_buttons( 102 | [self.assist_trigger_pin], 103 | lambda eventtime, state, gate=gate: self._handle_button_advance(eventtime, state, gate) 104 | ) 105 | 106 | self.operation[self._key(gate)] = ('off', 0) 107 | self.back_to_back_burst_count[gate] = 0 108 | self.burst_trigger_state[gate] = 0 109 | 110 | # Setup minimum number of gcode request queues 111 | self.gcrqs = {} 112 | for mcu_pin in self.motor_mcu_pins.values(): 113 | mcu = mcu_pin.get_mcu() 114 | # TODO Temporary workaround to allow Kalico to work since it lacks GCodeRequestQueue 115 | if hasattr(output_pin, 'GCodeRequestQueue'): 116 | self.gcrqs.setdefault(mcu, output_pin.GCodeRequestQueue(config, mcu, self._set_pin)) 117 | else: 118 | self.gcrqs.setdefault(mcu, GCodeRequestQueue(config, mcu, self._set_pin)) 119 | 120 | # Setup event handler for DC espooler motor operation 121 | self.printer.register_event_handler("mmu:espooler_advance", self._handle_espooler_advance) 122 | 123 | # Register event handlers 124 | self.printer.register_event_handler('klippy:ready', self._handle_ready) 125 | 126 | def _handle_ready(self): 127 | self.toolhead = self.printer.lookup_object('toolhead') 128 | self.mmu = self.printer.lookup_object('mmu') 129 | 130 | # Setup extruder monitor 131 | try: 132 | self.extruder_monitor = self.ExtruderMonitor(self) 133 | except Exception as e: 134 | self.mmu.log_error(str(e)) 135 | self.extruder_monitor = None 136 | 137 | def _key(self, gate): 138 | return '%s_gate_%d' % (self.name, gate) 139 | 140 | def _valid_gate(self, gate): 141 | return gate is not None and self.first_gate <= gate < self.first_gate + self.num_gates 142 | 143 | def _is_empty_pin(self, pin): 144 | if pin == '': return True 145 | ppins = self.printer.lookup_object('pins') 146 | pin_params = ppins.parse_pin(pin, can_invert=True, can_pullup=True) 147 | pin_resolver = ppins.get_pin_resolver(pin_params['chip_name']) 148 | real_pin = pin_resolver.aliases.get(pin_params['pin'], '_real_') 149 | return real_pin == '' 150 | 151 | # Callback from button sensor to initiate burst assist 152 | def _handle_button_advance(self, eventtime, state, gate): 153 | self.burst_trigger_state[gate] = state 154 | if self.mmu and self.mmu.espooler_assist_burst_trigger: # Don't handle if not ready or disabled 155 | if self.mmu.espooler_assist_burst_trigger and state and gate not in self.burst_gates: 156 | self.back_to_back_burst_count[gate] += 1 157 | self.advance(gate) 158 | elif not state and self.back_to_back_burst_count[gate] >= self.mmu.espooler_assist_burst_trigger_max: 159 | # Allow future triggers 160 | self.burst_gates.discard(gate) 161 | self.back_to_back_burst_count[gate] = 0 162 | 163 | def enable_burst_trigger(self, gate, enable): 164 | if self._valid_gate(gate): 165 | cur_enabled = self.burst_trigger_enabled.get(gate, False) 166 | if not cur_enabled and enable: 167 | # Turn on and if currently triggered immediately advance 168 | self.burst_trigger_enabled[gate] = True 169 | if self.burst_trigger_state.get(gate, 0): 170 | self.advance(gate) 171 | elif cur_enabled and not enable: 172 | self.burst_trigger_enabled[gate] = False 173 | 174 | # This resets burst trigger and repeats burst if sensor still triggered. It is used to cap the 175 | # back-to-back firing of burst triggers to prevent obvious overruns if sensor is defective 176 | def _reset_burst_trigger(self, gate): 177 | if gate in self.burst_gates: 178 | self._update(gate, 0, None) 179 | if self.burst_trigger_state.get(gate, 0): 180 | # Still triggered 181 | if self.back_to_back_burst_count[gate] < self.mmu.espooler_assist_burst_trigger_max: 182 | self.back_to_back_burst_count[gate] += 1 183 | self.advance(gate) 184 | else: 185 | self.mmu.log_error("Espooler assist suspended bcause of suspected malfunction. Assist sensor may be stuck in triggered state") 186 | else: 187 | # Allow future triggers 188 | self.burst_gates.discard(gate) 189 | self.back_to_back_burst_count[gate] = 0 190 | 191 | # Direct call to initiate burst assist 192 | def advance(self, gate=None): 193 | # Advance by "mmu defined" parameters 194 | self._handle_espooler_advance(gate, self.mmu.espooler_assist_burst_power / 100, self.mmu.espooler_assist_burst_duration) 195 | 196 | # This event will advance the espooler by the power/duration if in "in-print assist" mode 197 | def _handle_espooler_advance(self, gate, value, duration): 198 | from .mmu import Mmu # For operation names 199 | 200 | # If gate not specifed, find the active gate (there should only be one) 201 | gate = ( 202 | gate if gate is not None else 203 | next( 204 | (g for g in range(self.first_gate, self.first_gate + self.num_gates) 205 | if self.operation[self._key(g)][0] == Mmu.ESPOOLER_PRINT), 206 | None 207 | ) 208 | ) 209 | 210 | if self._valid_gate(gate): 211 | cur_op, cur_value = self.operation[self._key(gate)] 212 | msg = "Got espooler advance event for gate %d: value=%.2f duration=%.1f" % (gate, value, duration) 213 | if cur_op == Mmu.ESPOOLER_PRINT and cur_value == 0: 214 | self.mmu.log_debug(msg) 215 | self._update(gate, value, None) # On 216 | self.burst_gates.add(gate) # Should only be one gate at a time but this adds future flexibility 217 | waketime = self.reactor.monotonic() + duration 218 | self.reactor.register_callback(lambda pt: self._reset_burst_trigger(gate=gate), waketime) # Schedule off 219 | else: 220 | msg += " (Ignored because espooler state is %s, value: %.2f)" % (cur_op, cur_value) 221 | self.mmu.log_debug(msg) 222 | 223 | # Direct call to change the operation of the espooler 224 | def set_operation(self, gate, value, operation): 225 | from .mmu import Mmu # For operation names 226 | 227 | if self.mmu.log_enabled(Mmu.LOG_TRACE): 228 | self.mmu.log_trace("ESPOOLER: set_operation(gate=%s, value=%s, operation=%s)" % (gate, value, operation)) 229 | 230 | # Turn off assist for all gates except specified gate if still wanted 231 | for g in range(self.first_gate, self.first_gate + self.num_gates): 232 | if ( 233 | (self.operation[self._key(g)][0] == Mmu.ESPOOLER_PRINT and g != gate) or 234 | (g == gate and operation == Mmu.ESPOOLER_PRINT and value != 0) 235 | ): 236 | self._update(g, 0, Mmu.ESPOOLER_OFF) 237 | 238 | # Disable all triggers 239 | if self.mmu.espooler_assist_burst_trigger: 240 | self.enable_burst_trigger(g, False) 241 | if self.extruder_monitor: 242 | self.extruder_monitor.watch(False) 243 | if self.mmu.log_enabled(Mmu.LOG_TRACE): 244 | self.mmu.log_trace("ESPOOLER: In-print assist for gate %d canceled" % g) 245 | 246 | if gate is not None and gate >= 0: 247 | self.mmu.log_debug("Espooler for gate %d set to %s (pwm: %.2f)" % (gate, operation, value)) 248 | self._update(gate, value, operation) 249 | 250 | if operation == Mmu.ESPOOLER_PRINT and value == 0: 251 | if self.mmu.log_enabled(Mmu.LOG_TRACE): 252 | self.mmu.log_trace("ESPOOLER: Entering in-print assist mode for gate %d" % gate) 253 | 254 | # Enable appropriate trigger 255 | if self.mmu.espooler_assist_burst_trigger: 256 | self.enable_burst_trigger(gate, True) 257 | elif self.extruder_monitor: 258 | self.extruder_monitor.watch(True) 259 | 260 | def get_operation(self, gate): 261 | return self.operation.get(self._key(gate), ('off', 0)) 262 | 263 | # Set the PWM or digital signal 264 | def _update(self, gate, value, operation): 265 | from .mmu import Mmu # For operation names 266 | 267 | if self.mmu.log_enabled(Mmu.LOG_TRACE): 268 | self.mmu.log_trace("ESPOOLER: _update(%s, %s, %s)" % (gate, value, operation)) 269 | 270 | def _schedule_set_pin(name, value): 271 | mcu_pin = self.motor_mcu_pins.get(name, None) 272 | if mcu_pin: 273 | estimated_print_time = mcu_pin.get_mcu().estimated_print_time(self.printer.reactor.monotonic()) 274 | if self.mmu.log_enabled(Mmu.LOG_TRACE): 275 | self.mmu.log_trace("ESPOOLER: --> _schedule_set_pin(name=%s, value=%s) @ print_time: %.8f" % (name, value, estimated_print_time)) 276 | self.gcrqs[mcu_pin.get_mcu()].send_async_request((name, value)) 277 | 278 | # None operation is special case of updating without changing operation (typically in-print assist burst) 279 | if operation is None: 280 | operation = self.operation[self._key(gate)][0] 281 | elif operation == Mmu.ESPOOLER_OFF: 282 | value = 0 283 | 284 | # Clamp and scale value 285 | value = max(0, min(1, value)) / self.scale 286 | if not self.is_pwm: 287 | value = 1 if value > 0 else 0 288 | 289 | if self.operation[self._key(gate)] != (operation, value): 290 | 291 | if value == 0: # Stop motor 292 | _schedule_set_pin('enable_%d' % gate, 0) 293 | _schedule_set_pin('respool_%d' % gate, 0) 294 | _schedule_set_pin('assist_%d' % gate, 0) 295 | else: 296 | active_motor_name = 'respool_%d' % gate if operation == Mmu.ESPOOLER_REWIND else 'assist_%d' % gate 297 | inactive_motor_name = 'assist_%d' % gate if operation == Mmu.ESPOOLER_REWIND else 'respool_%d' % gate 298 | _schedule_set_pin(inactive_motor_name, 0) 299 | _schedule_set_pin(active_motor_name, value) 300 | _schedule_set_pin('enable_%d' % gate, 1) 301 | 302 | self.operation[self._key(gate)] = (operation, value) 303 | 304 | # This is the actual callback method to update pin signal (pwm or digital) 305 | def _set_pin(self, print_time, action): 306 | from .mmu import Mmu # For operation names 307 | 308 | name, value = action 309 | mcu_pin = self.motor_mcu_pins.get(name, None) 310 | if mcu_pin: 311 | if value == self.last_value.get(name, None): 312 | return 313 | if self.mmu.log_enabled(Mmu.LOG_TRACE): 314 | self.mmu.log_trace("ESPOOLER: -----> _set_pin(name=%s, value=%s) @ print_time: %.8f" % (name, value, print_time)) 315 | if self.is_pwm and not name.startswith('enable_'): 316 | mcu_pin.set_pwm(print_time, value) 317 | else: 318 | mcu_pin.set_digital(print_time, value) 319 | self.last_value[name] = value 320 | 321 | def get_status(self, eventtime): 322 | return { 323 | 'name': self.name, 324 | 'first_gate': self.first_gate, 325 | 'num_gates': self.num_gates, 326 | 'respool_gates': self.respool_gates, 327 | 'assist_gates': self.assist_gates 328 | } 329 | 330 | 331 | # Class to monitor extruder movement an generate espooler "advance" events 332 | class ExtruderMonitor: 333 | 334 | CHECK_MOVEMENT_PERIOD = 1. # How often to check extruder movement 335 | 336 | def __init__(self, espooler): 337 | self.espooler = espooler 338 | self.reactor = espooler.reactor 339 | self.estimated_print_time = espooler.printer.lookup_object('mcu').estimated_print_time 340 | self.extruder = espooler.printer.lookup_object(espooler.mmu.extruder_name, None) 341 | if not self.extruder: 342 | raise espooler.config.error("Extruder named `%s` not found. Espooler extruder monitor disabled" % espooler.mmu.extruder_name) 343 | 344 | self.enabled = False 345 | self.last_extruder_pos = None 346 | self._extruder_pos_update_timer = self.reactor.register_timer(self._extruder_pos_update_event) 347 | 348 | def watch(self, enable): 349 | if not self.enabled and enable: 350 | # Ensure first burst after initial extruder movement 351 | self.last_extruder_pos = self._get_extruder_pos() - self.espooler.mmu.espooler_assist_extruder_move_length + 1. 352 | self.enabled = True 353 | self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NOW) # Enabled 354 | elif not enable: 355 | self.last_extruder_pos = None 356 | self.enabled = False 357 | self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NEVER) # Disabled 358 | 359 | def _get_extruder_pos(self, eventtime=None): 360 | if eventtime is None: 361 | eventtime = self.reactor.monotonic() 362 | print_time = self.estimated_print_time(eventtime) 363 | if self.extruder: 364 | pos = self.extruder.find_past_position(print_time) 365 | return pos 366 | else: 367 | return 0. 368 | 369 | # Called periodically to check extruder movement 370 | def _extruder_pos_update_event(self, eventtime): 371 | extruder_pos = self._get_extruder_pos(eventtime) 372 | #self.espooler.mmu.log_trace("TEMP: current_extruder_pos: %s (last: %s)" % (extruder_pos, self.last_extruder_pos)) 373 | if self.last_extruder_pos is not None and extruder_pos > self.last_extruder_pos + self.espooler.mmu.espooler_assist_extruder_move_length: 374 | self.espooler.advance() # Initiate burst 375 | self.last_extruder_pos = extruder_pos 376 | return eventtime + self.CHECK_MOVEMENT_PERIOD 377 | 378 | def load_config_prefix(config): 379 | return MmuESpooler(config) 380 | 381 | 382 | ###################################################################### 383 | # G-Code request queuing helper 384 | # This is included to allow Kalico to work since it has not yet picked 385 | # up this klipper functionality 4/18/25 386 | # Copyright (C) 2017-2024 Kevin O'Connor 387 | ###################################################################### 388 | 389 | PIN_MIN_TIME = 0.100 390 | 391 | # Helper code to queue g-code requests 392 | class GCodeRequestQueue: 393 | def __init__(self, config, mcu, callback): 394 | self.printer = printer = config.get_printer() 395 | self.mcu = mcu 396 | self.callback = callback 397 | self.rqueue = [] 398 | self.next_min_flush_time = 0. 399 | self.toolhead = None 400 | mcu.register_flush_callback(self._flush_notification) 401 | printer.register_event_handler("klippy:connect", self._handle_connect) 402 | def _handle_connect(self): 403 | self.toolhead = self.printer.lookup_object('toolhead') 404 | def _flush_notification(self, print_time, clock): 405 | rqueue = self.rqueue 406 | while rqueue: 407 | next_time = max(rqueue[0][0], self.next_min_flush_time) 408 | if next_time > print_time: 409 | return 410 | # Skip requests that have been overridden with a following request 411 | pos = 0 412 | while pos + 1 < len(rqueue) and rqueue[pos + 1][0] <= next_time: 413 | pos += 1 414 | req_pt, req_val = rqueue[pos] 415 | # Invoke callback for the request 416 | min_wait = 0. 417 | ret = self.callback(next_time, req_val) 418 | if ret is not None: 419 | # Handle special cases 420 | action, min_wait = ret 421 | if action == "discard": 422 | del rqueue[:pos+1] 423 | continue 424 | if action == "delay": 425 | pos -= 1 426 | del rqueue[:pos+1] 427 | self.next_min_flush_time = next_time + max(min_wait, PIN_MIN_TIME) 428 | # Ensure following queue items are flushed 429 | self.toolhead.note_mcu_movequeue_activity(self.next_min_flush_time) 430 | def _queue_request(self, print_time, value): 431 | self.rqueue.append((print_time, value)) 432 | self.toolhead.note_mcu_movequeue_activity(print_time) 433 | def queue_gcode_request(self, value): 434 | self.toolhead.register_lookahead_callback( 435 | (lambda pt: self._queue_request(pt, value))) 436 | def send_async_request(self, value, print_time=None): 437 | if print_time is None: 438 | systime = self.printer.get_reactor().monotonic() 439 | print_time = self.mcu.estimated_print_time(systime + PIN_MIN_TIME) 440 | while 1: 441 | next_time = max(print_time, self.next_min_flush_time) 442 | # Invoke callback for the request 443 | action, min_wait = "normal", 0. 444 | ret = self.callback(next_time, value) 445 | if ret is not None: 446 | # Handle special cases 447 | action, min_wait = ret 448 | if action == "discard": 449 | break 450 | self.next_min_flush_time = next_time + max(min_wait, PIN_MIN_TIME) 451 | if action != "delay": 452 | break 453 | -------------------------------------------------------------------------------- /extras/mmu_led_effect.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Wrapper around led_effect klipper module to replicate any effect on entire strip as well 3 | # as on each individual LED for per-gate effects. This relies on a previous shared 4 | # [mmu_leds] section for the shared part of the config 5 | # 6 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 7 | # moggieuk@hotmail.com 8 | # 9 | # (\_/) 10 | # ( *,*) 11 | # (")_(") Happy Hare Ready 12 | # 13 | # This file may be distributed under the terms of the GNU GPLv3 license. 14 | # 15 | import logging 16 | 17 | # Klipper imports 18 | from .mmu_leds import MmuLeds 19 | 20 | class MmuLedEffect: 21 | 22 | def __init__(self, config): 23 | self.printer = config.get_printer() 24 | mmu_leds = self.printer.lookup_object('mmu_leds', None) 25 | define_on_str = config.get('define_on', "").strip() 26 | _ = config.get('layers') 27 | if mmu_leds: 28 | has_led_effects = mmu_leds.get_status().get('led_effect_module') 29 | frame_rate = mmu_leds.get_status().get('default_frame_rate') 30 | define_on = [segment.strip() for segment in define_on_str.split(',') if segment.strip()] 31 | if define_on and not all(e in MmuLeds.SEGMENTS for e in define_on): 32 | raise config.error("Unknown LED segment name specified in '%s'" % define_on_str) 33 | config.fileconfig.set(config.get_name(), 'frame_rate', config.get('frame_rate', frame_rate)) 34 | led_effect_section = config.get_name()[4:] # Remove "mmu_" 35 | 36 | # This condition makes it a no-op if [mmu_leds] is not present or led_effects not installed 37 | if has_led_effects: 38 | for segment in MmuLeds.SEGMENTS: 39 | led_segment_name = "mmu_%s_leds" % segment 40 | led_chain = self.printer.lookup_object("mmu_%s_leds" % segment) 41 | num_leds = led_chain.led_helper.led_count 42 | 43 | if num_leds > 0: 44 | # Full segment effects 45 | if not define_on or segment in define_on: 46 | section_to = "%s_%s" % (led_effect_section, segment) 47 | self._add_led_effect(config, section_to, led_segment_name) 48 | 49 | # Per gate 50 | if segment in MmuLeds.PER_GATE_SEGMENTS and not define_on and segment != 'status': 51 | for idx in range(num_leds): 52 | section_to = "%s_%s_%d" % (led_effect_section, segment, idx + 1) 53 | self._add_led_effect(config, section_to, "%s (%d)" % (led_segment_name, idx + 1)) 54 | 55 | def _add_led_effect(self, config, section_to, leds): 56 | config.fileconfig.add_section(section_to) 57 | config.fileconfig.set(section_to, 'leds', leds) 58 | items = config.fileconfig.items(config.get_name()) 59 | for item in (i for i in items if i[0] != 'define_on'): 60 | config.fileconfig.set(section_to, item[0], item[1]) 61 | new_object = config.getsection(section_to) 62 | try: 63 | _ = self.printer.load_object(config, new_object.get_name()) 64 | except Exception as e: 65 | raise config.error("Unable to create led effect`. It is likely you don't have the 'led_effect' klipper module installed. Exception: %s" % str(e)) 66 | 67 | def load_config_prefix(config): 68 | return MmuLedEffect(config) 69 | -------------------------------------------------------------------------------- /extras/mmu_leds.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # 3 | # Allows for flexible creation of virtual leds chains - one for each of the supported 4 | # segments (exit, entry, status). Entry and exit are indexed by gate number. 5 | # 6 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 7 | # moggieuk@hotmail.com 8 | # 9 | # (\_/) 10 | # ( *,*) 11 | # (")_(") Happy Hare Ready 12 | # 13 | # This file may be distributed under the terms of the GNU GPLv3 license. 14 | # 15 | import logging 16 | 17 | # Klipper imports 18 | from . import led as klipper_led 19 | 20 | class VirtualMmuLedChain: 21 | def __init__(self, config, segment, config_chains): 22 | self.printer = config.get_printer() 23 | self.name = "mmu_%s_leds" % segment 24 | self.config_chains = config_chains 25 | 26 | # Create temporary config section just to access led helper 27 | led_section = "led %s" % self.name 28 | config.fileconfig.add_section(led_section) 29 | led_config = config.getsection(led_section) 30 | self.led_helper = klipper_led.LEDHelper(led_config, self.update_leds, sum(len(leds) for chain_name, leds in config_chains)) 31 | config.fileconfig.remove_section(led_section) 32 | 33 | # We need to configure the chain now so we can validate 34 | self.leds = [] 35 | for chain_name, leds in self.config_chains: 36 | chain = self.printer.lookup_object(chain_name, None) 37 | if chain: 38 | for led in leds: 39 | self.leds.append((chain, led)) 40 | else: 41 | raise config.error("MMU LED chain '%s' referenced in '%s' doesn't exist" % (chain_name, self.name)) 42 | 43 | def update_leds(self, led_state, print_time): 44 | chains_to_update = set() 45 | for color, (chain, led) in zip(led_state, self.leds): 46 | chain.led_helper.led_state[led] = color 47 | chains_to_update.add(chain) 48 | for chain in chains_to_update: 49 | chain.led_helper.update_func(chain.led_helper.led_state, None) 50 | 51 | def get_status(self, eventtime=None): 52 | state = [] 53 | chain_status = {} 54 | for chain, led in self.leds: 55 | if chain not in chain_status: 56 | status = chain.led_helper.get_status(eventtime)['color_data'] 57 | chain_status[chain] = status 58 | state.append(chain_status[chain][led]) 59 | return {"color_data": state} 60 | 61 | 62 | class MmuLeds: 63 | 64 | PER_GATE_SEGMENTS = ['exit', 'entry'] 65 | SEGMENTS = PER_GATE_SEGMENTS + ['status', 'logo'] 66 | 67 | def __init__(self, config): 68 | self.printer = config.get_printer() 69 | 70 | self.num_gates = self.printer.lookup_object("mmu_machine").num_gates 71 | self.frame_rate = config.getint('frame_rate', 24) 72 | 73 | # Create virtual led chains 74 | self.virtual_chains = {} 75 | for segment in self.SEGMENTS: 76 | name = "%s_leds" % segment 77 | config_chains = [self.parse_chain(line) for line in config.get(name, '').split('\n') if line.strip()] 78 | self.virtual_chains[segment] = VirtualMmuLedChain(config, segment, config_chains) 79 | self.printer.add_object("mmu_%s" % name, self.virtual_chains[segment]) 80 | 81 | num_leds = len(self.virtual_chains[segment].leds) 82 | if segment in self.PER_GATE_SEGMENTS and num_leds > 0 and num_leds != self.num_gates: 83 | raise config.error("Number of MMU '%s' LEDs (%d) doesn't match num_gates (%d)" % (segment, num_leds, self.num_gates)) 84 | 85 | # Check for LED chain overlap or unavailable LEDs 86 | used = {} 87 | for segment in self.SEGMENTS: 88 | for led in self.virtual_chains[segment].leds: 89 | obj, index = led 90 | if index >= obj.led_helper.led_count: 91 | raise config.error("MMU LED (with index %d) on segment %s isn't available" % (index + 1, segment)) 92 | if led in used: 93 | raise config.error("Same MMU LED (with index %d) used more than one segment: %s and %s" % (index + 1, used[led], segment)) 94 | else: 95 | used[led] = segment 96 | 97 | # Check if LED effects module is installed 98 | self.led_effect_module = False 99 | try: 100 | _ = config.get_printer().load_object(config, 'led_effect') 101 | self.led_effect_module = True 102 | except Exception: 103 | pass 104 | 105 | def parse_chain(self, chain): 106 | chain = chain.strip() 107 | leds=[] 108 | parms = [parameter.strip() for parameter in chain.split() if parameter.strip()] 109 | if parms: 110 | chain_name = parms[0].replace(':',' ') 111 | led_indices = ''.join(parms[1:]).strip('()').split(',') 112 | for led in led_indices: 113 | if led: 114 | if '-' in led: 115 | start, stop = map(int,led.split('-')) 116 | if stop == start: 117 | ledList = [start-1] 118 | elif stop > start: 119 | ledList = list(range(start-1, stop)) 120 | else: 121 | ledList = list(reversed(range(stop-1, start))) 122 | for i in ledList: 123 | leds.append(int(i)) 124 | else: 125 | for i in led.split(','): 126 | leds.append(int(i)-1) 127 | return chain_name, leds 128 | else: 129 | return None, None 130 | 131 | def get_status(self, eventtime=None): 132 | status = {segment: len(self.virtual_chains[segment].leds) for segment in self.SEGMENTS} 133 | status.update({ 134 | 'led_effect_module': self.led_effect_module, 135 | 'num_gates': self.num_gates, 136 | 'default_frame_rate': self.frame_rate 137 | }) 138 | return status 139 | 140 | def load_config(config): 141 | return MmuLeds(config) 142 | -------------------------------------------------------------------------------- /extras/mmu_sensors.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Easy setup of all sensors for MMU 3 | # 4 | # Pre-gate sensors: 5 | # Simplifed filament switch sensor easy configuration of pre-gate sensors used to detect runout and insertion of filament 6 | # and preload into gate and update gate_map when possible to do so based on MMU state, not printer state 7 | # Essentially this uses the default `filament_switch_sensor` but then replaces the runout_helper 8 | # Each has name `mmu_pre_gate_X` where X is gate number 9 | # 10 | # mmu_gear sensor(s): 11 | # Wrapper around `filament_switch_sensor` setting up insert/runout callbacks with modified runout event handling 12 | # Named `mmu_gear` 13 | # 14 | # mmu_gate sensor(s): 15 | # Wrapper around `filament_switch_sensor` setting up insert/runout callbacks with modified runout event handling 16 | # Named `mmu_gate` 17 | # 18 | # extruder & toolhead sensor: 19 | # Wrapper around `filament_switch_sensor` disabling all functionality - just for visability 20 | # Named `extruder` & `toolhead` 21 | # 22 | # sync feedback sensor(s): 23 | # Creates buttons handlers (with filament_switch_sensor for visibility and control) and publishes events based on state change 24 | # Named `sync_feedback_compression` & `sync_feedback_tension` 25 | # 26 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 27 | # moggieuk@hotmail.com 28 | # 29 | # RunoutHelper based on: 30 | # Generic Filament Sensor Module Copyright (C) 2019 Eric Callahan 31 | # 32 | # (\_/) 33 | # ( *,*) 34 | # (")_(") Happy Hare Ready 35 | # 36 | # This file may be distributed under the terms of the GNU GPLv3 license. 37 | # 38 | import logging, time 39 | 40 | # Enhanced "runout helper" that gives greater control of when filament sensor events are fired and 41 | # direct access to button events in addition to creating a "remove" / "runout" distinction 42 | class MmuRunoutHelper: 43 | def __init__(self, printer, name, event_delay, insert_gcode, remove_gcode, runout_gcode, insert_remove_in_print, button_handler, switch_pin): 44 | 45 | self.printer, self.name = printer, name 46 | self.insert_gcode, self.remove_gcode, self.runout_gcode = insert_gcode, remove_gcode, runout_gcode 47 | self.insert_remove_in_print = insert_remove_in_print 48 | self.button_handler = button_handler 49 | self.switch_pin = switch_pin 50 | self.reactor = self.printer.get_reactor() 51 | self.gcode = self.printer.lookup_object('gcode') 52 | 53 | self.min_event_systime = self.reactor.NEVER 54 | self.event_delay = event_delay # Time between generated events 55 | self.filament_present = False 56 | self.sensor_enabled = True 57 | self.runout_suspended = None 58 | self.button_handler_suspended = False 59 | 60 | self.printer.register_event_handler("klippy:ready", self._handle_ready) 61 | 62 | # Replace previous runout_helper mux commands with ours 63 | prev = self.gcode.mux_commands.get("QUERY_FILAMENT_SENSOR") 64 | _, prev_values = prev 65 | prev_values[self.name] = self.cmd_QUERY_FILAMENT_SENSOR 66 | 67 | prev = self.gcode.mux_commands.get("SET_FILAMENT_SENSOR") 68 | _, prev_values = prev 69 | prev_values[self.name] = self.cmd_SET_FILAMENT_SENSOR 70 | 71 | def _handle_ready(self): 72 | self.min_event_systime = self.reactor.monotonic() + 2. # Time to wait before first events are processed 73 | 74 | def _insert_event_handler(self, eventtime): 75 | self._exec_gcode("%s EVENTTIME=%s" % (self.insert_gcode, eventtime)) 76 | 77 | def _remove_event_handler(self, eventtime): 78 | self._exec_gcode("%s EVENTTIME=%s" % (self.remove_gcode, eventtime)) 79 | 80 | def _runout_event_handler(self, eventtime): 81 | # Pausing from inside an event requires that the pause portion of pause_resume execute immediately. 82 | pause_resume = self.printer.lookup_object('pause_resume') 83 | pause_resume.send_pause_command() 84 | self._exec_gcode("%s EVENTTIME=%s" % (self.runout_gcode, eventtime)) 85 | 86 | def _exec_gcode(self, command): 87 | if command: 88 | try: 89 | self.gcode.run_script(command) 90 | except Exception: 91 | logging.exception("MMU: Error running mmu sensor handler: `%s`" % command) 92 | self.min_event_systime = self.reactor.monotonic() + self.event_delay 93 | 94 | # Latest klipper v0.12.0-462 added the passing of eventtime 95 | # old: note_filament_present(self, is_filament_present): 96 | # new: note_filament_present(self, eventtime, is_filament_present): 97 | def note_filament_present(self, *args): 98 | if len(args) == 1: 99 | eventtime = self.reactor.monotonic() 100 | is_filament_present = args[0] 101 | else: 102 | eventtime = args[0] 103 | is_filament_present = args[1] 104 | 105 | # Button handlers are used for sync feedback state switches 106 | if self.button_handler and not self.button_handler_suspended: 107 | self.button_handler(eventtime, is_filament_present, self) 108 | 109 | if is_filament_present == self.filament_present: return 110 | self.filament_present = is_filament_present 111 | 112 | # Don't handle too early or if disabled 113 | if eventtime >= self.min_event_systime and self.sensor_enabled: 114 | self._process_state_change(eventtime, is_filament_present) 115 | 116 | def _process_state_change(self, eventtime, is_filament_present): 117 | # Determine "printing" status 118 | now = self.reactor.monotonic() 119 | print_stats = self.printer.lookup_object("print_stats", None) 120 | if print_stats is not None: 121 | is_printing = print_stats.get_status(now)["state"] == "printing" 122 | else: 123 | is_printing = self.printer.lookup_object("idle_timeout").get_status(now)["state"] == "Printing" 124 | 125 | if is_filament_present and self.insert_gcode: # Insert detected 126 | if not is_printing or (is_printing and self.insert_remove_in_print): 127 | self.min_event_systime = self.reactor.NEVER 128 | #logging.info("MMU: filament sensor %s: insert event detected, Eventtime %.2f" % (self.name, eventtime)) 129 | self.reactor.register_callback(lambda reh: self._insert_event_handler(eventtime)) 130 | 131 | else: # Remove or Runout detected 132 | self.min_event_systime = self.reactor.NEVER 133 | if is_printing and self.runout_suspended is False and self.runout_gcode: 134 | #logging.info("MMU: filament sensor %s: runout event detected, Eventtime %.2f" % (self.name, eventtime)) 135 | self.reactor.register_callback(lambda reh: self._runout_event_handler(eventtime)) 136 | elif self.remove_gcode and (not is_printing or self.insert_remove_in_print): 137 | # Just a "remove" event 138 | #logging.info("MMU: filament sensor %s: remove event detected, Eventtime %.2f" % (self.name, eventtime)) 139 | self.reactor.register_callback(lambda reh: self._remove_event_handler(eventtime)) 140 | 141 | def enable_runout(self, restore): 142 | self.runout_suspended = not restore 143 | 144 | def enable_button_feedback(self, restore): 145 | self.button_handler_suspended = not restore 146 | 147 | def get_status(self, eventtime): 148 | return { 149 | "filament_detected": bool(self.filament_present), 150 | "enabled": bool(self.sensor_enabled), 151 | "runout_suspended": bool(self.runout_suspended), 152 | } 153 | 154 | cmd_QUERY_FILAMENT_SENSOR_help = "Query the status of the Filament Sensor" 155 | def cmd_QUERY_FILAMENT_SENSOR(self, gcmd): 156 | if self.filament_present: 157 | msg = "MMU Sensor %s: filament detected" % (self.name) 158 | else: 159 | msg = "MMU Sensor %s: filament not detected" % (self.name) 160 | gcmd.respond_info(msg) 161 | 162 | cmd_SET_FILAMENT_SENSOR_help = "Sets the filament sensor on/off" 163 | def cmd_SET_FILAMENT_SENSOR(self, gcmd): 164 | self.sensor_enabled = bool(gcmd.get_int("ENABLE", 1)) 165 | 166 | 167 | class MmuSensors: 168 | 169 | def __init__(self, config): 170 | from .mmu import Mmu # For sensor names 171 | 172 | self.INSERT_GCODE = "__MMU_SENSOR_INSERT" 173 | self.REMOVE_GCODE = "__MMU_SENSOR_REMOVE" 174 | self.RUNOUT_GCODE = "__MMU_SENSOR_RUNOUT" 175 | 176 | self.printer = config.get_printer() 177 | mmu_machine = self.printer.lookup_object("mmu_machine", None) 178 | num_units = mmu_machine.num_units if mmu_machine else 1 179 | event_delay = config.get('event_delay', 0.5) 180 | 181 | # Setup "mmu_pre_gate" sensors... 182 | for gate in range(23): 183 | switch_pin = config.get('pre_gate_switch_pin_%d' % gate, None) 184 | if switch_pin: 185 | 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) 186 | 187 | # Setup single "mmu_gate" sensor(s)... 188 | # (possible to be multiplexed on type-B designs) 189 | switch_pins = list(config.getlist('gate_switch_pin', [])) 190 | if switch_pins: 191 | if len(switch_pins) not in [ 1, num_units]: 192 | raise config.error("Invalid number of pins specified with gate_switch_pin. Expected 1 or %d but counted %d" % (num_units, len(switch_pins))) 193 | self._create_mmu_sensor(config, Mmu.SENSOR_GATE, None, switch_pins, event_delay, runout=True) 194 | 195 | # Setup "mmu_gear" sensors... 196 | for gate in range(23): 197 | switch_pin = config.get('post_gear_switch_pin_%d' % gate, None) 198 | if switch_pin: 199 | self._create_mmu_sensor(config, Mmu.SENSOR_GEAR_PREFIX, gate, switch_pin, event_delay, runout=True) 200 | 201 | # Setup single extruder (entrance) sensor... 202 | switch_pin = config.get('extruder_switch_pin', None) 203 | if switch_pin: 204 | self._create_mmu_sensor(config, Mmu.SENSOR_EXTRUDER_ENTRY, None, switch_pin, event_delay, insert=True, runout=True) 205 | 206 | # Setup single toolhead sensor... 207 | switch_pin = config.get('toolhead_switch_pin', None) 208 | if switch_pin: 209 | self._create_mmu_sensor(config, Mmu.SENSOR_TOOLHEAD, None, switch_pin, event_delay) 210 | 211 | # Setup motor syncing feedback sensors... 212 | # (possible to be multiplexed on type-B designs) 213 | switch_pins = list(config.getlist('sync_feedback_tension_pin', [])) 214 | if switch_pins: 215 | if len(switch_pins) not in [ 1, num_units]: 216 | 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))) 217 | self._create_mmu_sensor(config, Mmu.SENSOR_TENSION, None, switch_pins, 0, button_handler=self._sync_tension_callback) 218 | switch_pins = list(config.getlist('sync_feedback_compression_pin', [])) 219 | if switch_pins: 220 | if len(switch_pins) not in [ 1, num_units]: 221 | 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))) 222 | self._create_mmu_sensor(config, Mmu.SENSOR_COMPRESSION, None, switch_pins, 0, button_handler=self._sync_compression_callback) 223 | 224 | def _create_mmu_sensor(self, config, name_prefix, gate, switch_pins, event_delay, insert=False, remove=False, runout=False, insert_remove_in_print=False, button_handler=None): 225 | switch_pins = [switch_pins] if not isinstance(switch_pins, list) else switch_pins 226 | for unit, switch_pin in enumerate(switch_pins): 227 | if not self._is_empty_pin(switch_pin): 228 | name = "%s_%d" % (name_prefix, gate) if gate is not None else "unit_%d_%s" % (unit, name_prefix) if len(switch_pins) > 1 else name_prefix # Must match mmu_sensor_manager 229 | sensor = name if gate is not None else "%s_sensor" % name 230 | section = "filament_switch_sensor %s" % sensor 231 | config.fileconfig.add_section(section) 232 | config.fileconfig.set(section, "switch_pin", switch_pin) 233 | config.fileconfig.set(section, "pause_on_runout", "False") 234 | fs = self.printer.load_object(config, section) 235 | 236 | # Replace with custom runout_helper because of state specific behavior 237 | insert_gcode = ("%s SENSOR=%s%s" % (self.INSERT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if insert else None 238 | remove_gcode = ("%s SENSOR=%s%s" % (self.REMOVE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if remove else None 239 | runout_gcode = ("%s SENSOR=%s%s" % (self.RUNOUT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if runout else None 240 | ro_helper = MmuRunoutHelper(self.printer, sensor, event_delay, insert_gcode, remove_gcode, runout_gcode, insert_remove_in_print, button_handler, switch_pin) 241 | fs.runout_helper = ro_helper 242 | fs.get_status = ro_helper.get_status 243 | 244 | def _is_empty_pin(self, switch_pin): 245 | if switch_pin == '': return True 246 | ppins = self.printer.lookup_object('pins') 247 | pin_params = ppins.parse_pin(switch_pin, can_invert=True, can_pullup=True) 248 | pin_resolver = ppins.get_pin_resolver(pin_params['chip_name']) 249 | real_pin = pin_resolver.aliases.get(pin_params['pin'], '_real_') 250 | return real_pin == '' 251 | 252 | # Button event handlers for sync-feedback 253 | # Feedback state should be between -1 (expanded) and 1 (compressed) 254 | def _sync_tension_callback(self, eventtime, tension_state, runout_helper): 255 | from .mmu import Mmu # For sensor names 256 | tension_enabled = runout_helper.sensor_enabled 257 | compression_sensor = self.printer.lookup_object("filament_switch_sensor %s_sensor" % Mmu.SENSOR_COMPRESSION, None) 258 | has_active_compression = compression_sensor.runout_helper.sensor_enabled if compression_sensor else False 259 | compression_state = compression_sensor.runout_helper.filament_present if has_active_compression else False 260 | 261 | if tension_enabled: 262 | if has_active_compression: 263 | if tension_state == compression_state: 264 | event_value = 0 265 | elif tension_state and not compression_state: 266 | event_value = -1 267 | else: 268 | event_value = 1 269 | else: 270 | if tension_state : 271 | event_value = -1 272 | else: 273 | event_value = 1 274 | else: 275 | if has_active_compression: 276 | if compression_state: 277 | event_value = 1 278 | else: 279 | event_value = -1 280 | else: 281 | event_value = 0 282 | 283 | self.printer.send_event("mmu:sync_feedback", eventtime, event_value) 284 | 285 | def _sync_compression_callback(self, eventtime, compression_state, runout_helper): 286 | from .mmu import Mmu 287 | compression_enabled = runout_helper.sensor_enabled 288 | tension_sensor = self.printer.lookup_object("filament_switch_sensor %s_sensor" % Mmu.SENSOR_TENSION, None) 289 | has_active_tension = tension_sensor.runout_helper.sensor_enabled if tension_sensor else False 290 | tension_state = tension_sensor.runout_helper.filament_present if has_active_tension else False 291 | 292 | if compression_enabled: 293 | if has_active_tension: 294 | if tension_state == compression_state: 295 | event_value = 0 296 | elif compression_state and not tension_state: 297 | event_value = 1 298 | else: 299 | event_value = -1 300 | else: 301 | if compression_state: 302 | event_value = 1 303 | else: 304 | event_value = -1 305 | else: 306 | if has_active_tension: 307 | if tension_state: 308 | event_value = -1 309 | else: 310 | event_value = 1 311 | else: 312 | event_value = 0 313 | 314 | self.printer.send_event("mmu:sync_feedback", eventtime, event_value) 315 | 316 | def load_config(config): 317 | return MmuSensors(config) 318 | -------------------------------------------------------------------------------- /extras/mmu_servo.py: -------------------------------------------------------------------------------- 1 | # Happy Hare MMU Software 2 | # Custom servo support that carefully synchronizes PWM changes to avoid "kickback" caused 3 | # by a truncated final pulse with digital servos. 4 | # All existing servo functionality is available with the addition of a 'DURATION' 5 | # parameter for setting PWM pulse train with auto off 6 | # 7 | # Copyright (C) 2022-2025 moggieuk#6538 (discord) 8 | # moggieuk@hotmail.com 9 | # 10 | # Based on original servo.py Copyright (C) 2017-2020 Kevin O'Connor 11 | # 12 | # (\_/) 13 | # ( *,*) 14 | # (")_(") Happy Hare Ready 15 | # 16 | # This file may be distributed under the terms of the GNU GPLv3 license. 17 | # 18 | import logging, time 19 | 20 | SERVO_SIGNAL_PERIOD = 0.020 21 | PIN_MIN_TIME = 0.100 22 | 23 | class MmuServo: 24 | def __init__(self, config): 25 | self.printer = config.get_printer() 26 | self.min_width = config.getfloat('minimum_pulse_width', .001, above=0., below=SERVO_SIGNAL_PERIOD) 27 | self.max_width = config.getfloat('maximum_pulse_width', .002, above=self.min_width, below=SERVO_SIGNAL_PERIOD) 28 | self.max_angle = config.getfloat('maximum_servo_angle', 180.) 29 | self.angle_to_width = (self.max_width - self.min_width) / self.max_angle 30 | self.width_to_value = 1. / SERVO_SIGNAL_PERIOD 31 | self.last_value = self.last_value_time = 0. 32 | initial_pwm = 0. 33 | iangle = config.getfloat('initial_angle', None, minval=0., maxval=360.) 34 | if iangle is not None: 35 | initial_pwm = self._get_pwm_from_angle(iangle) 36 | else: 37 | iwidth = config.getfloat('initial_pulse_width', 0., minval=0., maxval=self.max_width) 38 | initial_pwm = self._get_pwm_from_pulse_width(iwidth) 39 | self.last_value = initial_pwm 40 | 41 | # 50% of the "off" period is the best place to change PWM signal 42 | self.pwm_period_safe_offset = SERVO_SIGNAL_PERIOD - (SERVO_SIGNAL_PERIOD - self.max_width) / 2 43 | 44 | # Setup mcu_servo pin 45 | ppins = self.printer.lookup_object('pins') 46 | self.mcu_servo = ppins.setup_pin('pwm', config.get('pin')) 47 | self.mcu_servo.setup_max_duration(0.) 48 | self.mcu_servo.setup_cycle_time(SERVO_SIGNAL_PERIOD) 49 | self.mcu_servo.setup_start_value(initial_pwm, 0.) 50 | 51 | # Register command 52 | servo_name = config.get_name().split()[1] 53 | gcode = self.printer.lookup_object('gcode') 54 | gcode.register_mux_command("SET_SERVO", "SERVO", servo_name, self.cmd_SET_SERVO, desc=self.cmd_SET_SERVO_help) 55 | 56 | def get_status(self, eventtime): 57 | return {'value': self.last_value} 58 | 59 | def _set_pwm(self, print_time, value, duration): 60 | if value == self.last_value: 61 | return 62 | 63 | print_time = max(print_time, self.last_value_time + PIN_MIN_TIME) 64 | pwm_start_time = self._get_synced_print_time(print_time) 65 | if duration is None: 66 | self.mcu_servo.set_pwm(pwm_start_time, value) 67 | self.last_value = value 68 | self.last_value_time = pwm_start_time 69 | else: 70 | # Translate duration to ticks to avoid any secondary mcu clock skew 71 | mcu = self.mcu_servo.get_mcu() 72 | cmd_clock = mcu.print_time_to_clock(pwm_start_time) 73 | burst = int(duration / SERVO_SIGNAL_PERIOD) * SERVO_SIGNAL_PERIOD 74 | cmd_clock += mcu.seconds_to_clock(max(SERVO_SIGNAL_PERIOD, burst) + self.pwm_period_safe_offset) 75 | pwm_end_time = mcu.clock_to_print_time(cmd_clock) 76 | # Schedule PWM burst 77 | self.mcu_servo.set_pwm(pwm_start_time, value) 78 | self.mcu_servo.set_pwm(pwm_end_time, 0.) 79 | # Update time tracking 80 | self.last_value = 0. 81 | self.last_value_time = pwm_end_time 82 | 83 | # Return a print_time that is a safe place to change PWM signal 84 | def _get_synced_print_time(self, print_time): 85 | if self.last_value != 0.: # If servo already off time syncing is not necessary 86 | skew = (print_time - self.last_value_time) % SERVO_SIGNAL_PERIOD 87 | print_time -= skew # Align on previous SERVO_SIGNAL_PERIOD boundary 88 | print_time += self.pwm_period_safe_offset 89 | return print_time 90 | 91 | def _get_pwm_from_angle(self, angle): 92 | angle = max(0., min(self.max_angle, angle)) 93 | width = self.min_width + angle * self.angle_to_width 94 | return width * self.width_to_value 95 | 96 | def _get_pwm_from_pulse_width(self, width): 97 | width = max(self.min_width, min(self.max_width, width)) if width else width 98 | return width * self.width_to_value 99 | 100 | cmd_SET_SERVO_help = "Set servo angle" 101 | def cmd_SET_SERVO(self, gcmd): 102 | duration = gcmd.get_float('DURATION', None, minval=PIN_MIN_TIME) 103 | width = gcmd.get_float('WIDTH', None) 104 | angle = gcmd.get_float('ANGLE', None) 105 | self.set_position(width, angle, duration) 106 | 107 | def set_position(self, width=None, angle=None, duration=None): 108 | duration = max(duration, SERVO_SIGNAL_PERIOD) if duration else None 109 | if width is not None or angle is not None: 110 | value = self._get_pwm_from_pulse_width(width) if width is not None else self._get_pwm_from_angle(angle) 111 | pt = self.printer.lookup_object('toolhead').get_last_move_time() 112 | self._set_pwm(pt, value, duration) 113 | 114 | def load_config_prefix(config): 115 | return MmuServo(config) 116 | -------------------------------------------------------------------------------- /installer-dev/.gitignore: -------------------------------------------------------------------------------- 1 | config/ 2 | -------------------------------------------------------------------------------- /installer-dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mkuf/klipper:latest 2 | 3 | USER root 4 | 5 | RUN apt update && apt install -y make 6 | 7 | USER klipper 8 | -------------------------------------------------------------------------------- /installer-dev/README.md: -------------------------------------------------------------------------------- 1 | # Installer dev 2 | 3 | This provides a quick way to run through the installer in a docker environment, making it more portable. 4 | 5 | > [!NOTE] 6 | > This will create/update configs at `/installer-dev/config`. You may then review the changes there 7 | > or completely remove the files and start from scratch. 8 | 9 | ## Usage 10 | 11 | ### Full install 12 | 13 | This will run the installer with `-i` which forces it to run through the questionaire. 14 | 15 | ```shell 16 | cd ./installer-dev 17 | docker compose run --build --rm install 18 | ``` 19 | 20 | ### Upgrade 21 | 22 | This will run the installer without `-i` which will perform a config upgrade. 23 | 24 | ```shell 25 | cd ./installer-dev 26 | docker compose run --build --rm upgrade 27 | ``` 28 | -------------------------------------------------------------------------------- /installer-dev/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _base: &base 3 | build: . 4 | environment: 5 | HOME: /opt 6 | volumes: 7 | - ./entrypoint.sh:/entrypoint.sh 8 | - ./config:/opt/printer_data/config 9 | - ../:/opt/Happy-Hare 10 | entrypoint: /entrypoint.sh 11 | command: ./install.sh -sz 12 | 13 | install: 14 | << : *base 15 | command: ./install.sh -siz 16 | 17 | upgrade: 18 | << : *base 19 | command: ./install.sh -sz 20 | -------------------------------------------------------------------------------- /installer-dev/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -x 4 | 5 | # there needs to be at least 1 line in printer.cfg for the installer to be able to add the includes 6 | if [ ! -f "${HOME}/printer_data/config/printer.cfg" ]; then 7 | echo '# Printer Config' >> "${HOME}/printer_data/config/printer.cfg" 8 | fi 9 | cd ~/Happy-Hare 10 | 11 | # run all the arguments as the command 12 | exec "$@" 13 | -------------------------------------------------------------------------------- /moonraker_update.txt: -------------------------------------------------------------------------------- 1 | [update_manager happy-hare] 2 | type: git_repo 3 | path: ~/Happy-Hare 4 | origin: https://github.com/moggieuk/Happy-Hare.git 5 | primary_branch: main 6 | managed_services: klipper 7 | 8 | [mmu_server] 9 | enable_file_preprocessor: True 10 | enable_toolchange_next_pos: True 11 | update_spoolman_location: True 12 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moggieuk/Happy-Hare/2788543d69ea0f9f3cdbf66fefd86bdb55646dfc/test/__init__.py -------------------------------------------------------------------------------- /test/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moggieuk/Happy-Hare/2788543d69ea0f9f3cdbf66fefd86bdb55646dfc/test/components/__init__.py -------------------------------------------------------------------------------- /test/components/test_mmu_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import unittest 4 | from unittest.mock import MagicMock 5 | 6 | from components.mmu_server import MmuServer 7 | 8 | class TestMmuServerFileProcessor(unittest.TestCase): 9 | TOOLCHANGE_FILEPATH = 'test/support/toolchange.gcode' 10 | NO_TOOLCHANGE_FILEPATH = 'test/support/no_toolchange.gcode' 11 | 12 | def setUp(self): 13 | self.subject = MmuServer(MagicMock()) 14 | shutil.copyfile('test/support/toolchange.orig.gcode', self.TOOLCHANGE_FILEPATH) 15 | shutil.copyfile('test/support/no_toolchange.orig.gcode', self.NO_TOOLCHANGE_FILEPATH) 16 | 17 | def tearDown(self): 18 | os.remove(self.TOOLCHANGE_FILEPATH) 19 | os.remove(self.NO_TOOLCHANGE_FILEPATH) 20 | 21 | def test_filelist_callback_when_enabled(self): 22 | self.subject.enable_file_preprocessor = True 23 | self.subject._write_mmu_metadata = MagicMock() 24 | 25 | self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.gcode'}}) 26 | 27 | self.subject._write_mmu_metadata.assert_called_once() 28 | 29 | def test_filelist_callback_when_disabled(self): 30 | self.subject.enable_file_preprocessor = False 31 | self.subject._write_mmu_metadata = MagicMock() 32 | 33 | self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.gcode'}}) 34 | 35 | self.subject._write_mmu_metadata.assert_not_called() 36 | 37 | def test_filelist_callback_when_wrong_event(self): 38 | self.subject.enable_file_preprocessor = False 39 | self.subject._write_mmu_metadata = MagicMock() 40 | 41 | self.subject._filelist_changed({'action': 'move_file', 'item': {'path': 'test.gcode'}}) 42 | 43 | self.subject._write_mmu_metadata.assert_not_called() 44 | 45 | def test_filelist_callback_when_wrong_file_type(self): 46 | self.subject.enable_file_preprocessor = False 47 | self.subject._write_mmu_metadata = MagicMock() 48 | 49 | self.subject._filelist_changed({'action': 'create_file', 'item': {'path': 'test.txt'}}) 50 | 51 | self.subject._write_mmu_metadata.assert_not_called() 52 | 53 | def test_write_mmu_metadata_when_writing_to_files(self): 54 | self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) 55 | 56 | with open(self.TOOLCHANGE_FILEPATH, 'r') as f: 57 | file_contents = f.read() 58 | self.assertIn('PRINT_START MMU_TOOLS_USED=0,1,3,4,5,12\n', file_contents) 59 | 60 | def test_write_mmu_metadata_when_no_toolchanges(self): 61 | self.subject._write_mmu_metadata(self.NO_TOOLCHANGE_FILEPATH) 62 | 63 | with open(self.NO_TOOLCHANGE_FILEPATH, 'r') as f: 64 | file_contents = f.read() 65 | self.assertIn('PRINT_START MMU_TOOLS_USED=\n', file_contents) 66 | 67 | def test_write_mmu_metadata_does_not_replace_comments(self): 68 | self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) 69 | 70 | with open(self.TOOLCHANGE_FILEPATH, 'r') as f: 71 | file_contents = f.read() 72 | self.assertIn('; start_gcode: PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools!', file_contents) 73 | 74 | def test_inject_tool_usage_called_if_placeholder(self): 75 | self.subject._inject_tool_usage = MagicMock() 76 | 77 | self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) 78 | 79 | self.subject._inject_tool_usage.assert_called() 80 | 81 | def test_inject_tool_usage_not_called_if_no_placeholder(self): 82 | # Call it once to remove the placeholder 83 | self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) 84 | self.subject._inject_tool_usage = MagicMock() 85 | 86 | self.subject._write_mmu_metadata(self.TOOLCHANGE_FILEPATH) 87 | 88 | self.subject._inject_tool_usage.assert_not_called() 89 | -------------------------------------------------------------------------------- /test/runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Happy Hare MMU Software 3 | # Test runner 4 | # 5 | # Copyright (C) 2023 Kieran Eglin <@kierantheman (discord)>, 6 | # 7 | # NOTE: in order for tests to get picked up automatically, you must do the following: 8 | # 1. Create a file in the test directory with the name test_*.py 9 | # 2. Create a class in that file that inherits from unittest.TestCase 10 | # 3. Ensure that each test directory has a blank file named `__init__.py` 11 | 12 | python3 -m unittest 13 | -------------------------------------------------------------------------------- /test/support/no_toolchange.orig.gcode: -------------------------------------------------------------------------------- 1 | PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! 2 | G1 F1200 3 | G1 X167.759 Y180.16 E.00802 4 | G1 X167.263 Y180.262 E.02305 5 | G1 X166.979 Y180.193 E.0133 6 | G1 X166.433 Y179.911 E.02797 7 | G1 X165.996 Y179.509 E.02703 8 | G1 X165.321 Y178.61 E.05117 9 | G1 X164.865 Y178.289 E.02538 10 | G1 X164.172 Y177.944 E.03524 11 | G1 X163.551 Y177.578 E.03281 12 | G1 X162.963 Y177.124 E.03382 13 | G1 X162.489 Y176.633 E.03107 14 | G1 X162.239 Y176.218 E.02205 15 | G1 X162.031 Y175.724 E.0244 16 | G1 X162.025 Y174.997 E.03309 17 | G1 X162.042 Y174.657 E.0155 18 | G1 X162.144 Y174.141 E.02394 19 | G1 X162.313 Y174.145 E.0077 20 | G1 X162.633 Y174.049 E.01521 21 | G1 X162.964 Y174.006 E.01519 22 | -------------------------------------------------------------------------------- /test/support/toolchange.orig.gcode: -------------------------------------------------------------------------------- 1 | PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! 2 | T0 3 | G1 F1200 4 | G1 X167.759 Y180.16 E.00802 5 | G1 X167.263 Y180.262 E.02305 6 | G1 X166.979 Y180.193 E.0133 7 | T1 8 | G1 X166.433 Y179.911 E.02797 9 | G1 X165.996 Y179.509 E.02703 10 | G1 X165.321 Y178.61 E.05117 11 | G1 X164.865 Y178.289 E.02538 12 | G1 X164.172 Y177.944 E.03524 13 | G1 X163.551 Y177.578 E.03281 14 | 15 | ; T7 16 | ; The above shouldn't count 17 | G1 X162.963 Y177.124 E.03382 18 | G1 X162.489 Y176.633 E.03107 19 | T12 ; Testing 2-digit numbers 20 | G1 X162.239 Y176.218 E.02205 21 | G1 X162.031 Y175.724 E.0244 22 | MMU_CHANGE_TOOL TOOL=3 23 | MMU_CHANGE_TOOL FOO=bar TOOL=4 BAZ=quz 24 | G1 X162.025 Y174.997 E.03309 25 | G1 X162.042 Y174.657 E.0155 26 | MMU_CHANGE_TOOL_STANDALONE TOOL=5 27 | G1 X162.144 Y174.141 E.02394 28 | SHOULDNT_COUNT ARG=T7 29 | G1 X162.313 Y174.145 E.0077 30 | G1 X162.633 Y174.049 E.01521 31 | G1 X162.964 Y174.006 E.01519 32 | ; simulating slicer metadata below (should not be replaced) 33 | ; start_gcode: PRINT_START MMU_TOOLS_USED=!mmu_inject_referenced_tools! 34 | --------------------------------------------------------------------------------