What Is the Starlex Tool Changer?
The STC — Swiss3dc‘s Starlex Tool Changer — is a light tool changing system for Klipper-based 3D printers. That word “light” is the key distinction from a conventional tool changer.
In a full tool changer (like the E3D or Prusa XL architecture), each tool is a complete, self-contained printhead: its own extruder stepper, hotend, heater, thermistor, and cooling fan. Swapping tools means swapping the entire drive-and-heat assembly. That gives maximum independence between tools but adds significant mass, cost, and mechanical complexity to each one.
The Starlex takes a different approach. Each tool is just a hotend assembly — a heater block, thermistor, nozzle, and cooling fan. There is no extruder stepper per tool. Instead, a single shared extruder stepper lives on the carriage (on the EBB36 toolhead board), and the cam mechanism physically couples it to whichever tool is currently docked. When the carriage picks up a tool, the coupling engages the filament path; when it drops the tool back to the rack, the coupling releases. The extrusion drive always stays on the carriage.
This keeps tool mass and cost low — you’re parking hotends, not printheads — while still delivering true per-material temperature isolation, no cross-contamination between materials, and independent Z offsets per tool. The tradeoff is that the coupling and filament path alignment have to work reliably across 12 slots, which is where most of the interesting engineering happens.
Ours runs 12 tools. That number drove almost every software decision we made.
The Hardware Stack
Before getting into the programming, it helps to understand what the software has to talk to. The machine is built on a BIGTREETECH Manta M8P V2.0 as the primary MCU, running Klipper firmware on a Raspberry Pi host. A CoreXY gantry handles XY motion; three independent Z screws handle bed levelling. The tool rack sits along the front-left of the build volume and is driven by a cam mechanism on a planetary-gearbox stepper — rotating the cam lifts or drops a coupling bar that locks or releases the toolhead on the carriage.
Figure 1 — Hardware communication hierarchy. The Manta M8P is the central MCU; the EBB36 toolhead board communicates over CAN, while two ReHeat A0 expansion boards connect via USB and collectively manage all 12 tool heaters, fans, thermistors, and sensors.
Each tool has independently is a heater, a thermistor, a cooling fan, a park-detection endstop, and a filament runout sensor. That adds up to 60 independent signals across 12 tools, which is why the ReHeat A0 expansion boards exist at all — and why they’re worth explaining.
The ReHeat A0 is a prototype board we developed in collaboration with Intelligent Agent, the Norwegian company behind the Recore 3D printer control board. The problem we brought to them was specific to light tool changing: when each tool is just a hotend assembly rather than a full printhead, you can realistically run many more tools than any existing expansion hardware was designed to manage. Standard toolhead boards assume one-board-per-tool. We needed one board per six tools. The ReHeat A0 — six heater outputs, six fan outputs, six thermistor inputs, twelve endstop inputs, all on an RP2354B MCU with USB-C and native Klipper support — was designed from scratch to fill that gap. It’s a direct product of the opportunity this kind of light tool changing architecture opens up.
Starting Ad-Hoc: Why Custom Macros First
When this project started we had two tools. Then four. Then twelve. The cam mechanism design changed. The location of the heater electronics changed — from the main board to the toolhead board to dedicated expansion boards. We were figuring out the mechanical coupling, the filament path geometry, and the slot spacing all at the same time we were writing configuration code.
Klipper’s macro and configuration language is surprisingly expressive for a config file: full Jinja2 templating, access to the complete printer state dictionary, named variables, deferred gcode execution, and arbitrary gcode command chaining. It lets you build a working tool changer from scratch without touching C++ — and more importantly, lets you change it again tomorrow when the hardware does something unexpected.
So we wrote everything custom. T-command macros, tool state tracking, rack control, fan management, offset persistence — all of it. It was the right call. But before we get to why, let’s go through what we had to actually solve.
1 The BDsensor Pin Conflict
The BDsensor is a capacitive Z probe that communicates over I²C. The logical place to connect it on the EBB36 was the PROBE port — pin PB8. It almost worked. The problem: that port has an opto-coupler sitting on the SDA line intended for traditional endstop signals, and the opto-coupler clamps the SDA signal in a way that corrupts I²C communication. The sensor would initialise but return garbage readings.
The fix was to find an actual I²C bus on the EBB36. Pins PA6 and PA7 are wired to a hardware I²C peripheral with no opto-coupler in the signal path:
# Don't use aliases for the board pins — use the raw I²C port
[BDsensor]
sda_pin: ^EBB36:PA6
scl_pin: EBB36:PA7
delay: 10
homing_cmd: G990028 # required when G28 is redefined
The homing_cmd: G990028 line is a less-obvious requirement: when you redefine G28 with rename_existing, the BDsensor’s internal auto-calibration sequence needs to know which gcode to call for homing. Without it, calibration triggers the macro wrapper and not the base G28.1, causing a loop.
2 The Tool Rack Cam Mechanism
The tool rack is a linear bar that runs across the front of the machine. Each tool parks in its slot; when the carriage approaches a slot and the cam rotates, a coupling bar lifts and locks the tool onto the carriage (or lowers and releases it back to the rack).
The cam is driven by a stepper motor with a 5:1 planetary gearbox, configured in Klipper as a manual_stepper. “Manual stepper” means Klipper doesn’t include this axis in normal motion planning — we drive it explicitly with MANUAL_STEPPER commands:
[manual_stepper tool_rack]
step_pin: PG9
dir_pin: PD7
enable_pin: !PG11
rotation_distance: 2 # 1 full cam rotation = 2mm virtual travel
gear_ratio: 5:1
microsteps: 4
velocity: 5
accel: 100
endstop_pin: ^!PF3
The interesting part is how we synchronise the cam with the extruder motor during pickup. The coupling mechanism works best when the extruder is also rotating slightly as the cam seats — it helps the filament path align. We used Klipper’s non-standard GCODE_AXIS=R parameter on the manual stepper to temporarily map the rack cam to a virtual “R” axis, then issued multi-axis G1 moves that simultaneously drove X (jiggle), E (extruder prime), and R (cam rotation):
# Temporarily expose cam as R axis so G1 can move all three in sync
MANUAL_STEPPER STEPPER=tool_rack GCODE_AXIS=R LIMIT_VELOCITY=20 LIMIT_ACCEL=100
G1 X0.1 E0.667 R0.667 F100 # jiggle right, prime, cam rotate down
G1 X-0.2 E0.333 R0.333 F100
G1 X0.1 E0.500 R-0.5 F100 # jiggle back, cam back up
G1 E0.500 R0.5 F100
G1 E0.180 R0.18 F100
MANUAL_STEPPER STEPPER=tool_rack GCODE_AXIS= # detach R axis
This “jiggle sequence” was developed empirically over many coupling attempts. The small X oscillation helps the mechanical coupling seat properly while the extruder pre-loads the filament path. The R axis trick passes through Klipper’s ToolGcodeTransform layer untouched, which matters once we add tool offsets to the picture.
3 Twelve Heaters in One Config Language
Klipper’s configuration is not a programming language. But Jinja2 templating inside gcode macros is a programming language, and the boundary between the two causes friction when you need to do things dynamically.
The fundamental challenge: tool 0 uses the [extruder] heater (a special Klipper section with its own namespace), while tools 1–11 use [heater_generic toolN_heater] sections. You can’t write a single expression that transparently handles both without knowing the tool number first.
In macros, Klipper exposes heater state through the printer dictionary. printer.extruder.temperature works for T0. For T1–T11 we needed string key lookup:
# Dynamic heater name construction in Jinja2
{% set hname = "heater_generic tool" ~ tool_num ~ "_heater" %}
{% set temp = printer[hname].temperature | default(0) %}
{% set target = printer[hname].target | default(0) %}
The | default(0) filter is essential. If a heater section isn’t defined (because we haven’t wired T5 yet, for example), printer["heater_generic tool5_heater"] returns an empty dict rather than raising an error. The default filter makes it safely return zero instead of crashing the macro.
This pattern appears in three different places: the M104/M109 routing overrides, the carriage fan check, and the parked fan update logic. Getting it wrong in any one of them caused either a fan that stayed off when it should be on, or a Klipper error that crashed the macro mid-print.
4 The Fan Management Problem
A tool changer has two distinct fan populations that need completely different control logic:
- Carriage fan — cools the active hotend. Should be on whenever the active tool’s heater is above 50°C or has a non-zero target.
- Parked fans — cool the toolheads sitting in the rack. Should be on when a parked tool’s heater is hot, but NOT when that tool is currently on the carriage (because the carriage fan handles it and the parked fan geometry doesn’t reach the active position anyway).
Twelve tools, one fan per two-tool pair, means 6 parked fans. Each fan’s on/off logic becomes:
Fan T(N)+T(N+1) is ON if: (T(N) is hot AND T(N) is not the active tool) OR (T(N+1) is hot AND T(N+1) is not the active tool)
We implemented this as _UPDATE_PARKED_FANS — a macro that reads the state of all 12 heaters (using the dynamic lookup described above) and issues SET_FAN_SPEED commands accordingly. This runs once on boot, once after every tool change, and on a 2-second timer loop:
[delayed_gcode CARRIAGE_FAN_LOOP]
initial_duration: 2
gcode:
_CARRIAGE_FAN_CHECK
UPDATE_DELAYED_GCODE ID=CARRIAGE_FAN_LOOP DURATION=2
One important safety detail: all parked fans are declared with shutdown_speed: 1. If Klipper crashes or shuts down in an error state, the fans spin up to full speed automatically. Parked toolheads that were hot when Klipper crashed get cooled rather than cooking.
5 Tool Offsets and State Persistence
Every tool has slightly different nozzle geometry, and mounting tolerances mean each one sits at a slightly different X, Y, and Z position relative to the machine home. We track per-tool offsets that Klipper applies to all subsequent move coordinates after a tool change.
Offsets are stored in saved_variables.cfg, a Klipper-native file that persists across restarts:
[save_variables]
filename: ~/printer_data/config/saved_variables.cfg
# In the variables file:
[Variables]
tool_offset_t0 = [0.0, 0.0, 0.3]
tool_offset_t1 = [-1.0, -0.5, 0.1]
tool_offset_t2 = [-0.4, 0.0, 0.4]
The Z calibration workflow is deliberately simple: with a tool selected, the user jogs Z down until a paper test feels right, then calls SAVE_TOOL_Z_OFFSET. That macro reads printer.gcode_move.gcode_position.z — the current position in gcode space — and writes it back to the save file.
State persistence across reboots was handled by a RESTORE_TOOL_ON_BOOT delayed gcode that fires 3 seconds after Klipper starts, reads the last active tool number from saved_variables.cfg, and re-applies its offsets. This prevents a scenario where the machine reboots in the middle of a print and loses track of which offsets are active.
6 Routing M104 and M109 to the Right Heater
Slicers emit standard M104 T1 S215 (set heater temperature, tool 1, 215°C) and M109 T0 S210 (wait for temperature). These commands assume a firmware that natively understands multiple extruder indices. Klipper’s M104/M109 implementation only talks to the [extruder] section.
The solution is to intercept those commands using Klipper’s rename_existing mechanism:
[gcode_macro M104]
rename_existing: M104.1
gcode:
{% set s = params.S | default(0) | float %}
{% set t = params.T | default(-1) | int %}
{% if t == -1 or t == 0 %}
M104.1 S{s} # route to [extruder]
{% elif t >= 1 and t <= 11 %}
SET_HEATER_TEMPERATURE HEATER={"tool" ~ t ~ "_heater"} TARGET={s}
{% endif %}
_CARRIAGE_FAN_CHECK
_UPDATE_PARKED_FANS
The fan checks are called inside M104 so that fan state updates immediately when a target temperature is set — even if it happens during a temperature wait that's blocking the main queue. The same pattern applies to our SET_HEATER_TEMPERATURE override so that any path that changes a heater target also triggers a fan update.
The M109 (blocking wait) version routes the T=0 case to M109.1 and the T=1–11 case to TEMPERATURE_WAIT SENSOR="heater_generic toolN_heater":
{% elif t >= 1 and t <= 11 %}
SET_HEATER_TEMPERATURE HEATER={"tool" ~ t ~ "_heater"} TARGET={s}
_CARRIAGE_FAN_CHECK
_UPDATE_PARKED_FANS
TEMPERATURE_WAIT SENSOR={"heater_generic tool" ~ t ~ "_heater"} MINIMUM={s - 2}
The MINIMUM={s - 2} gives a 2°C arrival window, preventing indefinite waits due to minor thermostat overshoot while still ensuring the heater is actually at temperature before printing begins.
7 Z Tilt Levelling With a Tool Changer
The machine uses three independent Z screws and Z_TILT_ADJUST to keep the bed level. The BDsensor probe is on the carriage, so levelling requires moving across the bed and probing multiple points. The problem: tool offsets applied to gcode coordinates would corrupt the probe position calculations.
The solution is to always run Z_TILT_ADJUST without any active tool offset. We wrapped it:
[gcode_macro Z_TILT_ADJUST]
rename_existing: _Z_TILT_ADJUST
gcode:
{% set saved_tool = printer["gcode_macro TOOL_VARS"].current_tool | int %}
{% if saved_tool != -1 %}
UNLOAD_TOOL # returns tool to rack, clears offsets
{% endif %}
_Z_TILT_ADJUST {rawparams}
{% if saved_tool != -1 %}
LOAD_TOOL TOOL={saved_tool} # reloads from rack, reapplies offsets
{% endif %}
In practice, PRINT_START explicitly unloads the tool before calling Z_TILT_ADJUST anyway. But the wrapper provides a safety net for manual levelling operations from KlipperScreen where a tool might already be loaded.
8 Filament Runout in a Multi-Tool System
Each tool has an independent filament runout sensor. Klipper's native pause_on_runout: True would fire a PAUSE for any sensor that triggers — including all 11 parked tools that have no filament loaded at all. That's obviously wrong.
The fix is a gating macro that only acts on runout events from the currently active tool:
[filament_switch_sensor tool0_runout]
switch_pin: ^reheat0:GPIO24
pause_on_runout: False
runout_gcode: _FILAMENT_RUNOUT_GATE TOOL=0
[gcode_macro _FILAMENT_RUNOUT_GATE]
gcode:
{% set sensor_tool = params.TOOL | int %}
{% set active_tool = printer["gcode_macro TOOL_VARS"].current_tool | int %}
{% if sensor_tool == active_tool %}
PAUSE
{% endif %}
All 12 sensors have pause_on_runout: False and call _FILAMENT_RUNOUT_GATE with their tool number. The macro compares that to the tracked active tool and only pauses when they match.
The Tool Change Sequence
All of these pieces combine into a choreographed tool change sequence. Every T{N} command works through the same path:
Figure 2 — Tool change sequence. The Z hop in the pre-change step is guarded — it only fires if a tool is currently mounted, because on the very first tool selection at print start there is nothing to hop over.
Current Configuration Architecture
The working configuration is split across several files, each with a clear responsibility boundary:
Figure 3 — Current ("frozen") configuration architecture. The saved_variables.cfg file is the persistence layer for tool state and offsets; everything else is pure Klipper config and macros.
Why Ad-Hoc Was the Right Strategy
With the benefit of hindsight, writing everything from scratch was the correct approach for this phase of the project. Here's why.
The hardware and software co-evolved. When we started, the tool rack cam was a different design. The heater electronics moved from the main board to the EBB36 to dedicated ReHeat expansion boards. The tool count grew from 2 to 12. The slot pitch changed. Any framework we adopted at the start would have needed continuous re-adaptation as the physical reality shifted under it — probably at every revision. Custom macros are easy to change.
Debugging hardware is easier without framework abstractions. When a coupling fails to engage, or a thermistor reads 400°C, or the cam overshoots, you need to see exactly what the firmware is telling the hardware. With custom macros you can action_respond_info() from anywhere, add temporary logging to any step, and change the sequence without understanding a plugin's internals. That was invaluable during mechanical development.
Iterative calibration required full control. Finding the right jiggle sequence, the right slot X positions, the right cam velocity, the right filament retract distance — all of that was tuned empirically over many tool-change attempts. A configuration language where every parameter is exposed directly in the config file (not buried in plugin code) made that iteration fast.
It built deep understanding. Every quirk of Klipper's gcode transform pipeline, macro execution order, and heater naming conventions was encountered, debugged, and understood first-hand. That understanding now directly informs how we're implementing the plugin migration — and what edge cases to watch for.
The frozen reference config (2026-06-02/config/) is exactly that: a known-good snapshot of what works at full hardware capability. It serves as the truth baseline as we build the new architecture beside it.
The Path Forward: klipper-toolchanger Plugin
Now that the hardware has stabilised — the cam mechanism is proven, the slot geometry is fixed, the ReHeat boards are defined — the maintenance burden of the custom approach becomes its primary drawback. Every user who builds this machine needs to understand the macro system from scratch. Every Klipper update risks breaking something subtle in the custom T-command macros. The code is inherently this-machine-specific.
The klipper-toolchanger plugin by Viesturz provides a purpose-built tool-changer framework for Klipper that solves the same problems we solved manually, but in a way the broader community can maintain. Key capabilities that align with our needs:
- Declarative tool definitions — each
[tool T0]through[tool T11]section carries its own offsets, heater reference, detection pin, and slot X position. The plugin registersT0–T11as gcode commands automatically. - ToolGcodeTransform — the plugin owns the gcode offset pipeline. Pickup and dropoff gcode runs in unoffset space (so rack coordinates are always absolute), then offsets are applied after the change is complete.
- save_current_tool: True — boot-time state restore is built in, replacing our
RESTORE_TOOL_ON_BOOTdelayed gcode. - initialize_on: home — the toolchanger initialises automatically on G28, no separate call needed from PRINT_START.
- t_command_restore_axis: XY — the plugin restores pre-change XY position automatically after every tool change; we only need to manage Z ourselves via the before/after hooks.
New Architecture
Figure 4 — New architecture with klipper-toolchanger plugin. The T0–T11 macros disappear entirely; the plugin registers those commands. Toolchanger.cfg is the new config hub for all per-tool data and the change sequences.
The key structural difference is that the custom T0–T11 gcode macros, the TOOL_VARS state-tracking macro, LOAD_TOOL, UNLOAD_TOOL, RESET_TOOL_STATE, and RESTORE_TOOL_ON_BOOT all disappear. The plugin provides equivalent functionality in a maintained, tested framework. What stays is the machine-specific knowledge: the slot positions, the jiggle sequence, the cam velocities, the offset values — all of which we already know from the ad-hoc phase.
The migration is being developed alongside the frozen config in a test_config/ directory. Nothing gets pushed to the machine until it's validated against the known-good reference.
The Value of Building It Wrong First
We built this system ad-hoc because we had to. The hardware was a moving target, the tool count was uncertain, and the mechanical design was being iterated in parallel with the firmware. No existing framework was going to survive all of that intact.
But the ad-hoc phase wasn't wasted effort — it was the research phase. Every solved problem above represents a real constraint on any tool-changer implementation: the offset pipeline has to zero before pickup gcode runs; the cam needs synchronised extruder movement to seat properly; parked fan logic needs per-tool heat state with an active-tool exclusion; filament runout needs a gating layer; M104 needs to route to different heater backends depending on the tool index. These aren't opinions. They're constraints we discovered by running the hardware.
We now know exactly what the plugin needs to do, and why, because we already did it ourselves. That's the value of building it wrong first.
What's Next
- Install the two ReHeat A0 boards and wire T0–T11 heaters, fans, thermistors, and endstops to them
- Install the klipper-toolchanger plugin and validate
test_config/against the frozen reference - Commission T3–T11 (currently all-zero offsets) one tool at a time
- Calibrate the filament prime distance per tool as material types are assigned
- Begin multi-material print testing with the full 12-tool rack populated
The goal was always a machine that could switch materials mid-print without operator intervention and without cross-contamination. The hardware is ready. The software architecture is ready. Now it's time to actually print with all twelve tools.
