├── .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 |
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 | ##    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 | 
97 |
98 | 

99 |
100 |
101 |
102 | ##    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 | ##    Documentation
114 |
115 |
116 |  |
117 |
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 | |
135 |
136 |
137 |
138 |
139 |
140 | ##    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 | 
145 | 
146 |
147 |
148 | ##    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 | 
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 |
--------------------------------------------------------------------------------