From 113e33a0a1686917a1eada0e71f7eefd6b85f0d6 Mon Sep 17 00:00:00 2001 From: Rogerio Goncalves Date: Fri, 2 Oct 2026 09:54:06 +0100 Subject: [PATCH 1/2] tmc: Support sensorless homing via SPI When no diag pin is configured on an SPI driver, the virtual_endstop makes the mcu poll the stallguard flag from the SPI status byte during homing and trigger trsync directly, for boards without the DIAG line wired. Signed-off-by: Rogerio Goncalves --- README.md | 2 + docs/Config_Reference.md | 9 ++++ docs/Kalico_Additions.md | 1 + docs/TMC_Drivers.md | 24 ++++++++- klippy/extras/tmc.py | 113 +++++++++++++++++++++++++++++++++++++-- src/Makefile | 2 +- src/tmc_spi_endstop.c | 113 +++++++++++++++++++++++++++++++++++++++ test/test_tmc_homing.py | 89 ++++++++++++++++++++++++++++++ 8 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 src/tmc_spi_endstop.c diff --git a/README.md b/README.md index 8f4775a363..f0c591c72f 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ See the [Kalico Additions document](https://docs.kalico.gg/Kalico_Additions.html - [indx: support for the Bondtech INDX toolboard](https://github.com/KalicoCrew/kalico/pull/904) +- [tmc: sensorless homing via SPI](https://github.com/KalicoCrew/kalico/pull/992) + If you're feeling adventurous, take a peek at the extra features in the bleeding-edge-v2 branch [feature documentation](docs/Bleeding_Edge.md) and [feature configuration reference](docs/Config_Reference_Bleeding_Edge.md): diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 272955f21c..826b9af7bb 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -4539,6 +4539,9 @@ sense_resistor: # "sensorless homing". (Be sure to also set driver_SGT to an # appropriate sensitivity value.) The default is to not enable # sensorless homing. +# If no diag pin is specified, the virtual_endstop is still available +# and the micro-controller polls the stallguard flag over SPI instead. +# See the TMC_Drivers.md document for details. ``` ### [tmc2208] @@ -4921,6 +4924,9 @@ run_current: # "sensorless homing". (Be sure to also set driver_SGT OR driver_SG4_THRS # to an appropriate sensitivity value.) The default is to not enable # sensorless homing. +# If no diag pin is specified, the virtual_endstop is still available +# and the micro-controller polls the stallguard flag over SPI instead. +# See the TMC_Drivers.md document for details. ``` ### [tmc5160] @@ -5075,6 +5081,9 @@ sense_resistor: # "sensorless homing". (Be sure to also set driver_SGT to an # appropriate sensitivity value.) The default is to not enable # sensorless homing. +# If no diag pin is specified, the virtual_endstop is still available +# and the micro-controller polls the stallguard flag over SPI instead. +# See the TMC_Drivers.md document for details. ``` ## Run-time stepper motor current configuration diff --git a/docs/Kalico_Additions.md b/docs/Kalico_Additions.md index 7967dcae7f..a33a38b688 100644 --- a/docs/Kalico_Additions.md +++ b/docs/Kalico_Additions.md @@ -64,6 +64,7 @@ ## TMC Drivers - [`[tmc2240] driver_CS and current_range`](./Config_Reference.md#tmc2240) let you tune the current scaler and current range of your tmc2240 drivers. +- [Sensorless homing via SPI](./TMC_Drivers.md#sensorless-homing-via-spi) polls the stallguard flag of SPI drivers, so no DIAG pin is needed. ## Macros diff --git a/docs/TMC_Drivers.md b/docs/TMC_Drivers.md index 07c07becd2..de50cb3ae4 100644 --- a/docs/TMC_Drivers.md +++ b/docs/TMC_Drivers.md @@ -164,7 +164,8 @@ A few prerequisites are needed to use sensorless homing: 2. SPI / UART interface of the TMC driver wired to micro-controller (stand-alone mode does not work). 3. The appropriate "DIAG" or "SG_TST" pin of TMC driver connected to - the micro-controller. + the micro-controller. (Not needed on SPI drivers, see + [Sensorless homing via SPI](#sensorless-homing-via-spi).) 4. The steps in the [config checks](Config_checks.md) document must be run to confirm the stepper motors are configured and working properly. @@ -258,6 +259,27 @@ homing. See the [config reference](Config_Reference.md#tmc-stepper-driver-configuration) for all the available options. +##### Sensorless homing via SPI + +On tmc2130, tmc2240, tmc5160 and tmc2160 drivers wired over SPI, the +DIAG pin is not required. If no `diag0_pin` or `diag1_pin` is set, the +micro-controller polls the driver's stallguard flag over the SPI bus +during homing instead of watching a pin: +``` +[tmc5160 stepper_x] +driver_SGT: 1 +... + +[stepper_x] +endstop_pin: tmc5160_stepper_x:virtual_endstop +... +``` +The flag is polled every 1ms and is ignored until the axis has reached +the homing speed (plus 50ms), so the homing move must start far enough +from the end of the rail to finish accelerating. Setting `min_home_dist` +in the stepper section (e.g. 15mm) makes Kalico back off and home again +when it starts too close. This is not available on UART drivers. + #### Find highest sensitivity that successfully homes Place the carriage near the center of the rail. Use the SET_TMC_FIELD diff --git a/klippy/extras/tmc.py b/klippy/extras/tmc.py index fcf074646c..221f9de8bd 100644 --- a/klippy/extras/tmc.py +++ b/klippy/extras/tmc.py @@ -6,7 +6,7 @@ import collections import logging -from klippy import stepper +from klippy import mcu, stepper from . import bulk_sensor @@ -735,7 +735,7 @@ def setup_pin(self, pin_type, pin_params): raise ppins.error("tmc virtual endstop only useful as endstop") if pin_params["invert"] or pin_params["pullup"]: raise ppins.error("Can not pullup/invert tmc virtual pin") - if self.diag_pin is None: + if self.diag_pin is None and not hasattr(self.mcu_tmc, "tmc_spi"): raise ppins.error("tmc virtual endstop requires diag pin config") # Setup for sensorless homing self.printer.register_event_handler( @@ -744,7 +744,10 @@ def setup_pin(self, pin_type, pin_params): self.printer.register_event_handler( "homing:homing_move_end", self.handle_homing_move_end ) - self.mcu_endstop = ppins.setup_pin("endstop", self.diag_pin) + if self.diag_pin is None: + self.mcu_endstop = TMCSpiEndstop(self.printer, self.mcu_tmc) + else: + self.mcu_endstop = ppins.setup_pin("endstop", self.diag_pin) return self.mcu_endstop def _set_field(self, field_name, value): @@ -797,6 +800,110 @@ def handle_homing_move_end(self, hmove): self._prev_state.clear() +# Sensorless homing by polling the stallguard flag over spi +class TMCSpiEndstop: + POLL_TIME = 0.001 + # Stallguard needs some time at speed before its flag is valid + SETTLE_TIME = 0.150 + + def __init__(self, printer, mcu_tmc): + tmc_spi = mcu_tmc.tmc_spi + self._printer = printer + self._mutex = mcu_tmc.mutex + self._mcu_tmc = mcu_tmc + self._mcu = tmc_spi.get_mcu() + self._oid = self._mcu.create_oid() + self._dispatch = mcu.TriggerDispatch(self._mcu) + self._home_cmd = self._query_cmd = None + self._mcu.add_config_cmd( + "config_tmc_spi_endstop oid=%d spi_oid=%d data_len=%d" + " status_pos=%d" + % ( + self._oid, + tmc_spi.spi.get_oid(), + tmc_spi.chain_len * 5, + (tmc_spi.chain_len - mcu_tmc.chain_pos) * 5, + ) + ) + self._mcu.add_config_cmd( + "tmc_spi_endstop_home oid=%d clock=0 rest_ticks=0" + " trsync_oid=0 trigger_reason=0" % (self._oid,), + on_restart=True, + ) + self._mcu.register_config_callback(self._build_config) + + def _build_config(self): + cmd_queue = self._dispatch.get_command_queue() + self._home_cmd = self._mcu.lookup_command( + "tmc_spi_endstop_home oid=%c clock=%u rest_ticks=%u" + " trsync_oid=%c trigger_reason=%c", + cq=cmd_queue, + ) + self._query_cmd = self._mcu.lookup_query_command( + "tmc_spi_endstop_query_state oid=%c", + "tmc_spi_endstop_state oid=%c trigger_clock=%u", + oid=self._oid, + cq=cmd_queue, + ) + + def get_mcu(self): + return self._mcu + + def add_stepper(self, stepper): + self._dispatch.add_stepper(stepper) + + def get_steppers(self): + return self._dispatch.get_steppers() + + def home_start( + self, print_time, sample_time, sample_count, rest_time, triggered=True + ): + toolhead = self._printer.lookup_object("toolhead") + accel = toolhead.get_max_velocity()[1] + speed = self.get_steppers()[0].get_step_dist() / rest_time + guard_time = speed / accel + self.SETTLE_TIME + # Host register reads must not interleave with the mcu polling + self._mutex.lock() + clock = self._mcu.print_time_to_clock(print_time + guard_time) + trigger_completion = self._dispatch.start(print_time) + self._home_cmd.send( + [ + self._oid, + clock, + self._mcu.seconds_to_clock(self.POLL_TIME), + self._dispatch.get_oid(), + mcu.MCU_trsync.REASON_ENDSTOP_HIT, + ], + reqclock=clock, + ) + return trigger_completion + + def home_wait(self, home_end_time): + try: + self._dispatch.wait_end(home_end_time) + self._home_cmd.send([self._oid, 0, 0, 0, 0]) + finally: + self._mutex.unlock() + res = self._dispatch.stop() + if res >= mcu.MCU_trsync.REASON_COMMS_TIMEOUT: + cmderr = self._printer.command_error + raise cmderr("Communication timeout during homing") + if res != mcu.MCU_trsync.REASON_ENDSTOP_HIT: + return 0.0 + if self._mcu.is_fileoutput(): + return home_end_time + params = self._query_cmd.send([self._oid]) + clock = self._mcu.clock32_to_clock64(params["trigger_clock"]) + return self._mcu.clock_to_print_time(clock) + + def query_endstop(self, print_time): + if self._mcu.is_fileoutput(): + return 0 + status = self._mcu_tmc.get_register_raw("DRV_STATUS")["spi_status"] + # Ignore the stale stall flag at standstill + return status & 0x0C == 0x04 + + ###################################################################### # Config reading helpers ###################################################################### diff --git a/src/Makefile b/src/Makefile index 2914a26315..0527e15621 100644 --- a/src/Makefile +++ b/src/Makefile @@ -4,7 +4,7 @@ src-y += sched.c command.c basecmd.c debugcmds.c src-$(CONFIG_HAVE_GPIO) += initial_pins.c gpiocmds.c stepper.c endstop.c \ trsync.c src-$(CONFIG_WANT_ADC) += adccmds.c -src-$(CONFIG_WANT_SPI) += spicmds.c +src-$(CONFIG_WANT_SPI) += spicmds.c tmc_spi_endstop.c src-$(CONFIG_WANT_I2C) += i2ccmds.c src-$(CONFIG_WANT_HARD_PWM) += pwmcmds.c src-$(CONFIG_HAVE_GPIO_SDIO) += sdiocmds.c diff --git a/src/tmc_spi_endstop.c b/src/tmc_spi_endstop.c new file mode 100644 index 0000000000..bbf12bbc19 --- /dev/null +++ b/src/tmc_spi_endstop.c @@ -0,0 +1,113 @@ +// Sensorless homing by polling the tmc stallguard flag over spi +// +// Copyright (C) 2026 Rogerio Goncalves +// +// This file may be distributed under the terms of the GNU GPLv3 license. + +#include // memset +#include "basecmd.h" // oid_alloc +#include "board/irq.h" // irq_disable +#include "board/misc.h" // timer_read_time +#include "command.h" // DECL_COMMAND +#include "sched.h" // DECL_TASK +#include "spicmds.h" // spidev_transfer +#include "trsync.h" // trsync_do_trigger + +#define MAX_DATA_LEN 40 +#define SPI_STATUS_SG2 0x04 + +struct tmc_spi_endstop { + struct timer timer; + uint32_t rest_ticks, trigger_clock; + struct spidev_s *spi; + struct trsync *ts; + uint8_t pending, data_len, status_pos, trigger_reason; +}; + +static struct task_wake tmc_spi_endstop_wake; + +static uint_fast8_t +tmc_spi_endstop_event(struct timer *t) +{ + struct tmc_spi_endstop *e = container_of(t, struct tmc_spi_endstop, timer); + e->pending = 1; + sched_wake_task(&tmc_spi_endstop_wake); + e->timer.waketime += e->rest_ticks; + return SF_RESCHEDULE; +} + +void +command_config_tmc_spi_endstop(uint32_t *args) +{ + uint8_t data_len = args[2], status_pos = args[3]; + if (data_len > MAX_DATA_LEN || status_pos >= data_len) + shutdown("Invalid tmc_spi_endstop config"); + struct tmc_spi_endstop *e = oid_alloc( + args[0], command_config_tmc_spi_endstop, sizeof(*e)); + e->timer.func = tmc_spi_endstop_event; + e->spi = spidev_oid_lookup(args[1]); + e->data_len = data_len; + e->status_pos = status_pos; +} +DECL_COMMAND(command_config_tmc_spi_endstop, + "config_tmc_spi_endstop oid=%c spi_oid=%c data_len=%c" + " status_pos=%c"); + +void +command_tmc_spi_endstop_home(uint32_t *args) +{ + struct tmc_spi_endstop *e = oid_lookup( + args[0], command_config_tmc_spi_endstop); + sched_del_timer(&e->timer); + e->pending = 0; + e->rest_ticks = args[2]; + if (!e->rest_ticks) { + e->ts = NULL; + return; + } + e->timer.waketime = e->trigger_clock = args[1]; + e->ts = trsync_oid_lookup(args[3]); + e->trigger_reason = args[4]; + sched_add_timer(&e->timer); +} +DECL_COMMAND(command_tmc_spi_endstop_home, + "tmc_spi_endstop_home oid=%c clock=%u rest_ticks=%u" + " trsync_oid=%c trigger_reason=%c"); + +void +command_tmc_spi_endstop_query_state(uint32_t *args) +{ + struct tmc_spi_endstop *e = oid_lookup( + args[0], command_config_tmc_spi_endstop); + sendf("tmc_spi_endstop_state oid=%c trigger_clock=%u" + , args[0], e->trigger_clock); +} +DECL_COMMAND(command_tmc_spi_endstop_query_state, + "tmc_spi_endstop_query_state oid=%c"); + +void +tmc_spi_endstop_task(void) +{ + if (!sched_check_wake(&tmc_spi_endstop_wake)) + return; + uint8_t oid; + struct tmc_spi_endstop *e; + foreach_oid(oid, e, command_config_tmc_spi_endstop) { + if (!e->pending) + continue; + irq_disable(); + e->pending = 0; + irq_enable(); + uint8_t data[MAX_DATA_LEN]; + memset(data, 0, e->data_len); + uint32_t time = timer_read_time(); + spidev_transfer(e->spi, 1, e->data_len, data); + if (!(data[e->status_pos] & SPI_STATUS_SG2)) + continue; + sched_del_timer(&e->timer); + e->pending = 0; + e->trigger_clock = time; + trsync_do_trigger(e->ts, e->trigger_reason); + } +} +DECL_TASK(tmc_spi_endstop_task); diff --git a/test/test_tmc_homing.py b/test/test_tmc_homing.py index ced072924c..555a62e67a 100644 --- a/test/test_tmc_homing.py +++ b/test/test_tmc_homing.py @@ -345,3 +345,92 @@ def test_post_homing_path_runs_even_if_pre_did_nothing(): assert dwell_pre == 0.0 assert dwell_post == ch.current_change_dwell_time assert ch.actual_current == pytest.approx(0.8) + + +class _FakeSpiMCU: + def __init__(self): + self.config_cmds = [] + + def create_oid(self): + return 7 + + def add_config_cmd(self, cmd, on_restart=False): + self.config_cmds.append(cmd) + + def register_config_callback(self, cb): + pass + + +class _FakeSpiPrinter: + command_error = RuntimeError + + +class _FakeDispatch: + def __init__(self, mcu): + self.res = 0 + self.fail_wait = False + + def wait_end(self, end_time): + if self.fail_wait: + raise RuntimeError("wait failed") + + def stop(self): + return self.res + + +class _FakeCmd: + def send(self, data, **kw): + pass + + +def _make_spi_endstop(monkeypatch, chain_len, chain_pos): + from klippy import reactor + from klippy.extras import tmc + + monkeypatch.setattr(tmc.mcu, "TriggerDispatch", _FakeDispatch) + fake_mcu = _FakeSpiMCU() + spi = type("Spi", (), {"get_oid": lambda self: 3})() + tmc_spi = type( + "Chain", + (), + {"chain_len": chain_len, "spi": spi, "get_mcu": lambda s: fake_mcu}, + )() + mcu_tmc = type("MCUTMC", (), {})() + mcu_tmc.tmc_spi = tmc_spi + mcu_tmc.chain_pos = chain_pos + mcu_tmc.mutex = reactor.SelectReactor().mutex() + endstop = tmc.TMCSpiEndstop(_FakeSpiPrinter(), mcu_tmc) + endstop._home_cmd = endstop._query_cmd = _FakeCmd() + return endstop, fake_mcu, mcu_tmc.mutex + + +def test_spi_endstop_status_pos_in_chain(monkeypatch): + _, fake_mcu, _ = _make_spi_endstop(monkeypatch, 3, 1) + assert ( + "config_tmc_spi_endstop oid=7 spi_oid=3 data_len=15 status_pos=10" + in fake_mcu.config_cmds + ) + + +def test_spi_endstop_releases_mutex_on_error(monkeypatch): + endstop, _, mutex = _make_spi_endstop(monkeypatch, 1, 1) + mutex.lock() + endstop._dispatch.fail_wait = True + with pytest.raises(RuntimeError): + endstop.home_wait(1.0) + assert not mutex.test() + + +def test_spi_endstop_unlocks_before_trsync_stop(monkeypatch): + # trsync stop syncs stepper positions, which reads tmc registers + endstop, _, mutex = _make_spi_endstop(monkeypatch, 1, 1) + mutex.lock() + locked_at_stop = [] + + def stop(): + locked_at_stop.append(mutex.test()) + return 0 + + endstop._dispatch.stop = stop + assert endstop.home_wait(1.0) == 0.0 + assert locked_at_stop == [False] From cf7a9569d2ecf2c7c38f379039e36d57e2c04dee Mon Sep 17 00:00:00 2001 From: Rogerio Goncalves Date: Tue, 6 Oct 2026 22:45:58 +0100 Subject: [PATCH 2/2] tmc: Skip SPI endstop on limited code size MCUs tmc_spi_endstop.c overflowed the 32KB flash on STM32F042. Build it only when HAVE_LIMITED_CODE_SIZE is not set. Signed-off-by: Rogerio Goncalves --- src/Kconfig | 4 ++++ src/Makefile | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Kconfig b/src/Kconfig index 2900d52a5e..12cb464d70 100644 --- a/src/Kconfig +++ b/src/Kconfig @@ -156,6 +156,10 @@ config WANT_TMCUART bool depends on HAVE_GPIO default y +config WANT_TMC_SPI_ENDSTOP + bool + depends on WANT_SPI && !HAVE_LIMITED_CODE_SIZE + default y config WANT_NEOPIXEL bool depends on HAVE_GPIO diff --git a/src/Makefile b/src/Makefile index 0527e15621..2cd5a4dc75 100644 --- a/src/Makefile +++ b/src/Makefile @@ -4,13 +4,14 @@ src-y += sched.c command.c basecmd.c debugcmds.c src-$(CONFIG_HAVE_GPIO) += initial_pins.c gpiocmds.c stepper.c endstop.c \ trsync.c src-$(CONFIG_WANT_ADC) += adccmds.c -src-$(CONFIG_WANT_SPI) += spicmds.c tmc_spi_endstop.c +src-$(CONFIG_WANT_SPI) += spicmds.c src-$(CONFIG_WANT_I2C) += i2ccmds.c src-$(CONFIG_WANT_HARD_PWM) += pwmcmds.c src-$(CONFIG_HAVE_GPIO_SDIO) += sdiocmds.c src-$(CONFIG_WANT_BUTTONS) += buttons.c src-$(CONFIG_WANT_TMCUART) += tmcuart.c +src-$(CONFIG_WANT_TMC_SPI_ENDSTOP) += tmc_spi_endstop.c src-$(CONFIG_WANT_NEOPIXEL) += neopixel.c src-$(CONFIG_WANT_PULSE_COUNTER) += pulse_counter.c src-$(CONFIG_WANT_ST7920) += lcd_st7920.c