Identified and fixed two distinct bugs affecting non-critical CAN MCUs (specifically Cartographer V4 on a toolchanger with pogo-pin connection, but applicable to any is_non_critical: True CAN MCU).
Please pardon the formatting, can't get it to quite work in Git (first time submitting an issue) - the fixes I have applied on my local machine were produced with Claude - I'm not a programmer.
I was able to get my Cartographer set as no_critical and now it survives disconnect/reconnect and Klipper restarts. I also had to modify The following files:
Bug 1: Non-critical CAN MCU fails to connect on Klippy restart when node ID is retained
Affected file: klippy/serialhdl.py — check_canbus_connect()
Root cause:
_mcu_identify() calls _check_serial_exists() for non-critical MCUs before attempting connect_canbus(). _check_serial_exists() uses check_canbus_connect(), which only sends CMD_QUERY_UNASSIGNED. However, when a CAN MCU retains its assigned node ID across a Klippy restart (Cartographer V4 does not reset its node ID assignment when Klippy disconnects), it does not respond to unassigned queries. As a result, _check_serial_exists() returns False, non_critical_disconnected is set to True, and connect_canbus() is never attempted. The MCU stays disconnected indefinitely.
Without is_non_critical, connect_canbus() is called directly during startup and works correctly because it sends CMD_SET_NODEID, which Cartographer V4 does respond to even with an existing node ID.
Reproduction: Set is_non_critical: True on any CAN MCU that retains its node ID across Klippy restarts. Restart Klippy with the MCU powered and connected. It will never connect.
Fix: Extend check_canbus_connect() to also attempt a CMD_SET_NODEID ping on the node's assigned txid when the unassigned query fails:
python# After the unassigned query times out, try pinging the assigned txid:
if canbus_nodeid is not None:
try:
txid = canbus_nodeid * 2 + 256
uuid_bytes = [(uuid >> (40 - i * 8)) & 0xFF for i in range(6)]
CMD_SET_NODEID = 0x01
set_id_cmd = [CMD_SET_NODEID] + uuid_bytes + [canbus_nodeid]
set_id_msg = can.Message(
arbitration_id=CANBUS_ID_ADMIN,
data=set_id_cmd,
is_extended_id=False,
)
filters2 = [{"can_id": txid + 1, "can_mask": 0x7FF, "extended": False}]
bus2 = can.interface.Bus(channel=canbus_iface, can_filters=filters2,
bustype="socketcan")
bus2.send(set_id_msg)
start_time2 = curtime2 = self.reactor.monotonic()
while True:
tdiff2 = start_time2 + 0.1 - curtime2
if tdiff2 <= 0.0:
break
msg2 = bus2.recv(tdiff2)
curtime2 = self.reactor.monotonic()
if msg2 is not None and msg2.arbitration_id == txid + 1:
bus2.close = bus2.shutdown
return True
bus2.close = bus2.shutdown
except (can.CanError, os.error) as e:
logging.warning("%scan assigned check issue: %s", self.warn_prefix, e)
Bug 2: Non-critical MCU firmware shutdown cascades emergency_stop to all other MCUs
Affected file: klippy/mcu.py — _handle_shutdown()
Root cause:
When a non-critical MCU's firmware sends a shutdown message (e.g. Cartographer disconnecting via pogo pins sends a final shutdown frame as the connection drops), _handle_shutdown() calls self._printer.invoke_async_shutdown(). This fires klippy:shutdown globally, which calls _shutdown() on every MCU including critical ones (EBBCan, main MCU). Each sends emergency_stop_cmd, putting their STM32 firmware into hardware shutdown state. This state persists in MCU firmware across Klippy restarts, causing the next startup to fail with:
Can not update MCU 'EBBCan' config as it is shutdown
The is_non_critical flag correctly prevents the printer from crashing on MCU timeout via _handle_starting, but does not prevent firmware-level shutdown messages from cascading to other MCUs.
Reproduction: Have a non-critical CAN MCU physically disconnect mid-session (e.g. pogo pins losing contact). The disconnecting MCU sends a shutdown frame before going offline. All other MCUs receive emergency_stop and enter hardware shutdown state. The next Klippy restart fails with the zombie shutdown error.
Fix: In _handle_shutdown(), redirect non-critical MCU shutdowns to handle_non_critical_disconnect() via register_async_callback. The reactor dispatch is required because _handle_shutdown runs in the serial background thread, while handle_non_critical_disconnect calls _disconnect() which joins that same thread — calling it directly causes RuntimeError: cannot join current thread:
pythonif self.is_non_critical:
logging.info(
"Non-critical MCU '%s' shutdown: %s - handling as disconnect",
self._name, msg,
)
self._is_shutdown = False
# Must schedule via register_async_callback: _handle_shutdown runs in
# the serial background thread; handle_non_critical_disconnect calls
# _disconnect() which joins that thread, causing deadlock if called
# directly.
self._reactor.register_async_callback(
lambda e: self.handle_non_critical_disconnect()
)
return
self._printer.invoke_async_shutdown(
prefix + msg + error_help(msg=msg, append_msgs=append_msgs)
)
Bug 3: Race condition in canbus_stats.py causes Klippy crash on non-critical MCU disconnect
Affected file: klippy/extras/canbus_stats.py
Root cause:
PrinterCANBusStats polls the CAN bus status via get_canbus_status_cmd.send() on a timer. When a non-critical MCU disconnects (e.g. Cartographer via pogo pins), the stats timer can fire while the MCU serial connection is already torn down. get_canbus_status_cmd.send() raises an exception into an unhandled context, crashing Klippy rather than gracefully reporting the disconnected state.
Additionally, check_canbus_connect() in serialhdl.py used a 1.0 second receive timeout per poll cycle. On a system where the non-critical MCU is frequently absent (T0 undocked), this caused the recon timer to block the reactor for 1 second per attempt, causing Timer too close errors on other MCUs.
Fix 1 — canbus_stats.py: Wrap get_canbus_status_cmd.send() in a try/except and return a disconnected status gracefully:
pythontry:
params = self.get_canbus_status_cmd.send()
except Exception:
self.status = {
"rx_error": prev_rx,
"tx_error": prev_tx,
"tx_retries": prev_retries,
"bus_state": "disconnected",
}
return self.reactor.monotonic() + 1.0
Fix 2 — serialhdl.py: Reduce the check_canbus_connect() receive timeout from 1.0s to 0.1s to avoid blocking the reactor:
python# Before:
tdiff = start_time + 1.0 - curtime
After:
tdiff = start_time + 0.1 - curtime
Complete diff
diffdiff --git a/klippy/extras/canbus_stats.py b/klippy/extras/canbus_stats.py
index 1c0841997..e7ac67271 100644
--- a/klippy/extras/canbus_stats.py
+++ b/klippy/extras/canbus_stats.py
@@ -85,7 +85,16 @@ class PrinterCANBusStats:
if prev_rx is None:
prev_rx = prev_tx = prev_retries = 0
-
params = self.get_canbus_status_cmd.send()
-
-
params = self.get_canbus_status_cmd.send()
-
-
-
-
-
"tx_retries": prev_retries,
-
"bus_state": "disconnected",
-
-
return self.reactor.monotonic() + 1.0
rx = prev_rx + ((params["rx_error"] - prev_rx) & 0xFFFFFFFF)
diff --git a/klippy/serialhdl.py b/klippy/serialhdl.py
--- a/klippy/serialhdl.py
+++ b/klippy/serialhdl.py
@@ check_canbus_connect receive timeout
-
tdiff = start_time + 1.0 - curtime
-
tdiff = start_time + 0.1 - curtime
@@ after unassigned query fails, also try assigned node ping
-
if canbus_nodeid is not None:
-
-
txid = canbus_nodeid * 2 + 256
-
uuid_bytes = [(uuid >> (40 - i * 8)) & 0xFF for i in range(6)]
-
set_id_cmd = [0x01] + uuid_bytes + [canbus_nodeid]
-
set_id_msg = can.Message(arbitration_id=CANBUS_ID_ADMIN,
-
data=set_id_cmd, is_extended_id=False)
-
filters2 = [{"can_id": txid+1, "can_mask": 0x7FF, "extended": False}]
-
bus2 = can.interface.Bus(channel=canbus_iface,
-
can_filters=filters2, bustype="socketcan")
-
-
start_time2 = curtime2 = self.reactor.monotonic()
-
-
tdiff2 = start_time2 + 0.1 - curtime2
-
-
-
-
curtime2 = self.reactor.monotonic()
-
if msg2 is not None and msg2.arbitration_id == txid + 1:
-
bus2.close = bus2.shutdown
-
-
bus2.close = bus2.shutdown
-
except (can.CanError, os.error) as e:
-
logging.warning("%scan assigned check issue: %s",
-
diff --git a/klippy/mcu.py b/klippy/mcu.py
--- a/klippy/mcu.py
+++ b/klippy/mcu.py
@@ _handle_shutdown, before invoke_async_shutdown
-
-
-
"Non-critical MCU '%s' shutdown: %s - handling as disconnect",
-
-
-
self._is_shutdown = False
-
self._reactor.register_async_callback(
-
lambda e: self.handle_non_critical_disconnect()
-
-
Kalico version: v2026.06.00-5-g30c65e09d
Hardware: BTT Manta M8P + CM4, Cartographer V4 6.1.0 on can1, EBB36 on can0/can1, MadMax toolchanger with pogo-pin T0 connection
Identified and fixed two distinct bugs affecting non-critical CAN MCUs (specifically Cartographer V4 on a toolchanger with pogo-pin connection, but applicable to any is_non_critical: True CAN MCU).
Please pardon the formatting, can't get it to quite work in Git (first time submitting an issue) - the fixes I have applied on my local machine were produced with Claude - I'm not a programmer.
I was able to get my Cartographer set as no_critical and now it survives disconnect/reconnect and Klipper restarts. I also had to modify The following files:
Bug 1: Non-critical CAN MCU fails to connect on Klippy restart when node ID is retained
Affected file: klippy/serialhdl.py — check_canbus_connect()
Root cause:
_mcu_identify() calls _check_serial_exists() for non-critical MCUs before attempting connect_canbus(). _check_serial_exists() uses check_canbus_connect(), which only sends CMD_QUERY_UNASSIGNED. However, when a CAN MCU retains its assigned node ID across a Klippy restart (Cartographer V4 does not reset its node ID assignment when Klippy disconnects), it does not respond to unassigned queries. As a result, _check_serial_exists() returns False, non_critical_disconnected is set to True, and connect_canbus() is never attempted. The MCU stays disconnected indefinitely.
Without is_non_critical, connect_canbus() is called directly during startup and works correctly because it sends CMD_SET_NODEID, which Cartographer V4 does respond to even with an existing node ID.
Reproduction: Set is_non_critical: True on any CAN MCU that retains its node ID across Klippy restarts. Restart Klippy with the MCU powered and connected. It will never connect.
Fix: Extend check_canbus_connect() to also attempt a CMD_SET_NODEID ping on the node's assigned txid when the unassigned query fails:
python# After the unassigned query times out, try pinging the assigned txid:
if canbus_nodeid is not None:
try:
txid = canbus_nodeid * 2 + 256
uuid_bytes = [(uuid >> (40 - i * 8)) & 0xFF for i in range(6)]
CMD_SET_NODEID = 0x01
set_id_cmd = [CMD_SET_NODEID] + uuid_bytes + [canbus_nodeid]
set_id_msg = can.Message(
arbitration_id=CANBUS_ID_ADMIN,
data=set_id_cmd,
is_extended_id=False,
)
filters2 = [{"can_id": txid + 1, "can_mask": 0x7FF, "extended": False}]
bus2 = can.interface.Bus(channel=canbus_iface, can_filters=filters2,
bustype="socketcan")
bus2.send(set_id_msg)
start_time2 = curtime2 = self.reactor.monotonic()
while True:
tdiff2 = start_time2 + 0.1 - curtime2
if tdiff2 <= 0.0:
break
msg2 = bus2.recv(tdiff2)
curtime2 = self.reactor.monotonic()
if msg2 is not None and msg2.arbitration_id == txid + 1:
bus2.close = bus2.shutdown
return True
bus2.close = bus2.shutdown
except (can.CanError, os.error) as e:
logging.warning("%scan assigned check issue: %s", self.warn_prefix, e)
Bug 2: Non-critical MCU firmware shutdown cascades emergency_stop to all other MCUs
Affected file: klippy/mcu.py — _handle_shutdown()
Root cause:
When a non-critical MCU's firmware sends a shutdown message (e.g. Cartographer disconnecting via pogo pins sends a final shutdown frame as the connection drops), _handle_shutdown() calls self._printer.invoke_async_shutdown(). This fires klippy:shutdown globally, which calls _shutdown() on every MCU including critical ones (EBBCan, main MCU). Each sends emergency_stop_cmd, putting their STM32 firmware into hardware shutdown state. This state persists in MCU firmware across Klippy restarts, causing the next startup to fail with:
Can not update MCU 'EBBCan' config as it is shutdown
The is_non_critical flag correctly prevents the printer from crashing on MCU timeout via _handle_starting, but does not prevent firmware-level shutdown messages from cascading to other MCUs.
Reproduction: Have a non-critical CAN MCU physically disconnect mid-session (e.g. pogo pins losing contact). The disconnecting MCU sends a shutdown frame before going offline. All other MCUs receive emergency_stop and enter hardware shutdown state. The next Klippy restart fails with the zombie shutdown error.
Fix: In _handle_shutdown(), redirect non-critical MCU shutdowns to handle_non_critical_disconnect() via register_async_callback. The reactor dispatch is required because _handle_shutdown runs in the serial background thread, while handle_non_critical_disconnect calls _disconnect() which joins that same thread — calling it directly causes RuntimeError: cannot join current thread:
pythonif self.is_non_critical:
logging.info(
"Non-critical MCU '%s' shutdown: %s - handling as disconnect",
self._name, msg,
)
self._is_shutdown = False
# Must schedule via register_async_callback: _handle_shutdown runs in
# the serial background thread; handle_non_critical_disconnect calls
# _disconnect() which joins that thread, causing deadlock if called
# directly.
self._reactor.register_async_callback(
lambda e: self.handle_non_critical_disconnect()
)
return
self._printer.invoke_async_shutdown(
prefix + msg + error_help(msg=msg, append_msgs=append_msgs)
)
Bug 3: Race condition in canbus_stats.py causes Klippy crash on non-critical MCU disconnect
Affected file: klippy/extras/canbus_stats.py
Root cause:
PrinterCANBusStats polls the CAN bus status via get_canbus_status_cmd.send() on a timer. When a non-critical MCU disconnects (e.g. Cartographer via pogo pins), the stats timer can fire while the MCU serial connection is already torn down. get_canbus_status_cmd.send() raises an exception into an unhandled context, crashing Klippy rather than gracefully reporting the disconnected state.
Additionally, check_canbus_connect() in serialhdl.py used a 1.0 second receive timeout per poll cycle. On a system where the non-critical MCU is frequently absent (T0 undocked), this caused the recon timer to block the reactor for 1 second per attempt, causing Timer too close errors on other MCUs.
Fix 1 — canbus_stats.py: Wrap get_canbus_status_cmd.send() in a try/except and return a disconnected status gracefully:
pythontry:
params = self.get_canbus_status_cmd.send()
except Exception:
self.status = {
"rx_error": prev_rx,
"tx_error": prev_tx,
"tx_retries": prev_retries,
"bus_state": "disconnected",
}
return self.reactor.monotonic() + 1.0
Fix 2 — serialhdl.py: Reduce the check_canbus_connect() receive timeout from 1.0s to 0.1s to avoid blocking the reactor:
python# Before:
tdiff = start_time + 1.0 - curtime
After:
tdiff = start_time + 0.1 - curtime
Complete diff
diffdiff --git a/klippy/extras/canbus_stats.py b/klippy/extras/canbus_stats.py
index 1c0841997..e7ac67271 100644
--- a/klippy/extras/canbus_stats.py
+++ b/klippy/extras/canbus_stats.py
@@ -85,7 +85,16 @@ class PrinterCANBusStats:
if prev_rx is None:
prev_rx = prev_tx = prev_retries = 0
diff --git a/klippy/serialhdl.py b/klippy/serialhdl.py
--- a/klippy/serialhdl.py
+++ b/klippy/serialhdl.py
@@ check_canbus_connect receive timeout
@@ after unassigned query fails, also try assigned node ping
diff --git a/klippy/mcu.py b/klippy/mcu.py
--- a/klippy/mcu.py
+++ b/klippy/mcu.py
@@ _handle_shutdown, before invoke_async_shutdown
Kalico version: v2026.06.00-5-g30c65e09d
Hardware: BTT Manta M8P + CM4, Cartographer V4 6.1.0 on can1, EBB36 on can0/can1, MadMax toolchanger with pogo-pin T0 connection