MAX31865 clone problems

Basic Information:

Printer Model: Ender 3 Pro
MCU / Printerboard: BTT SKR Mini e3v3
Host / SBC: Odroid XU4
klippy.log

klippy_log_thermocouple_fault.txt (29.8 KB)

Keep getting thermocouple faults from my (purple chinese clone) max31865 + PT100. Thought the max might just be faulty, but found it works fine hooked directly to a raspberry pi using:


sudo python3 - <<‘EOF’
import spidev
import time

R_REF = 430.0
RTD_A = 3.9083e-3
RTD_B = -5.775e-7

spi = spidev.SpiDev()
spi.open(0, 0)
spi.max_speed_hz = 500000
spi.mode = 1

def read_rtd_raw():
spi.xfer2([0x80, 0xC2])
time.sleep(0.1)
resp = spi.xfer2([0x01, 0x00, 0x00])
raw = (resp[1] << 8) | resp[2]
raw >>= 1
return raw

def rtd_to_temp(rtd_code):
R_rtd = (rtd_code / 32768.0) * R_REF
temp = (-RTD_A + (RTD_A**2 - 4RTD_B(1 - R_rtd/100))**0.5) / (2*RTD_B)
return temp

try:
while True:
raw = read_rtd_raw()
temp_c = rtd_to_temp(raw)
print(“Raw: {}, Temp: {:.2f}°C”.format(raw, temp_c))
time.sleep(1)
except KeyboardInterrupt:
spi.close()
print(“Exiting…”)
EOF


Notice the time.sleep pause between writing and reading. Apparently people have found this necessary with these clones.
Same with enabling vbias before enabling autoconvert. So I modified klippers max31865 driver (in spi_temperature.py) to do the same:


def __init__(self, config):
    rtd_nominal_r = config.getfloat('rtd_nominal_r', 100., above=0.)
    rtd_reference_r = config.getfloat('rtd_reference_r', 430., above=0.)
    adc_to_resist = rtd_reference_r / float(MAX31865_ADC_MAX)
    self.adc_to_resist_div_nominal = adc_to_resist / rtd_nominal_r
    #new version test below
    import time
    # Step 1: Enable VBIAS + clear faults
    cmd = 0x80 + MAX31865_CONFIG_REG
    value = MAX31865_CONFIG_BIAS | MAX31865_CONFIG_FAULTCLEAR
    self.config_reg = [cmd, value]
    SensorBase.__init__(self, config, "MAX31865", self.config_reg)
    # Small delay to let VBIAS stabilize
    time.sleep(0.1)  # 100 ms, same as RasPi
    # Step 2: Enable auto-convert (keep VBIAS on)
    value |= MAX31865_CONFIG_MODEAUTO
    self.config_reg = [cmd, value]
    self.spi.spi_send(self.config_reg)
    #original below
    #self.config_reg = self.build_spi_init(config)
    #SensorBase.__init__(self, config, "MAX31865", self.config_reg)
    # <-- Add this line to give the chip time after config write
    #import time
    #time.sleep(0.10)  # 50 ms delay

But I’m still getting the same thermocouple faults (all of them):


Starting heater checks for heater_bed
Max31865 RTD input is disconnected
Max31865 RTD input is shorted
Max31865 VREF- is greater than 0.85 * VBIAS, FORCE- open
Max31865 VREF- is less than 0.85 * VBIAS, FORCE- open
Max31865 VRTD- is less than 0.85 * VBIAS, FORCE- open
Max31865 Overvoltage or undervoltage fault
Starting heater checks for extruder
Max31865 RTD input is disconnected
Max31865 RTD input is shorted
Max31865 VREF- is greater than 0.85 * VBIAS, FORCE- open
Max31865 VREF- is less than 0.85 * VBIAS, FORCE- open
Max31865 VRTD- is less than 0.85 * VBIAS, FORCE- open
Max31865 Overvoltage or undervoltage fault
Stats 27476.7: gcodein=0 mcu: mcu_awake=0.000 mcu_task_avg=0.000000 mcu_task_stddev=0.000000 bytes_write=2985 bytes_read=7119 bytes_retransmit=9 bytes_invalid=0 send_seq=262 receive_seq=262 retransmit_seq=2 srtt=0.000 rttvar=0.000 rto=0.025 ready_bytes=0 upcoming_bytes=0 freq=63999127 heater_bed: target=0 temp=21.5 pwm=0.000 print_time=1149.677 buffer_time=0.250 print_stall=0 extruder: target=0 temp=0.0 pwm=0.000 sysload=0.00 cputime=0.840 memavail=1831732
Max31865 RTD input is disconnected
Max31865 RTD input is shorted
Max31865 VREF- is greater than 0.85 * VBIAS, FORCE- open
Max31865 VREF- is less than 0.85 * VBIAS, FORCE- open
Max31865 VRTD- is less than 0.85 * VBIAS, FORCE- open
Max31865 Overvoltage or undervoltage fault
Transition to shutdown state: MCU shutdown


Not sure where to go from here. It’s wired up correctly, jumper set for two wire… again, it worked fine on the raspi. I’m wondering if there is some quirk about the SKR Mini e3 v3 that is coming into play here like logic voltage (add pullups maybe?), or if it’s more differences with how klipper handles the max relative to how it was handled on my raspi.

Any and all help would be appreciated. Surely someone else has dealt with this and solved it before.

I fixed it.

This is the modified MAX31865 class for the spi_temperature.py file found at ~/klipper/klippy/extras/spi_temperature.py I created with the help of Anthropic Claude 4.5 Sonnet. I started working on it with ChatGPT but it couldn’t figure it out. Claude helped me finish it. As stated above, it basically changes the initialization by adding some pauses, and enabling vbias, pausing and then enabling autoconvert. Compare it to the original on github to see the other little differences that it needed to make that work. I believe these changes are only necessary with the clone boards because they’re kind of janky. But they’re like 1/4 the price. There may be unnecessary lines in there because I don’t 100% know what I’m doing. I left some debug lines in there but commented them out after it started working, too.

Initially I thought the board had a manufacturing defect that was causing it to fault, because it looked like they swapped a white capacitor in for the (typically grey or black) ferrite bead on the board (a 50hz filter on one RTD line, IIRC), and my cheap (actually malfunctioning, apparently - at least for resistance measurement) multimeter seemed to indicate it was a cap (a manufacturing flaw others have noted on clone boards), so I desoldered it and temporarily bridged the pads (the filter isn’t 100% required, but a cap breaks it), but that didn’t change the behavior. I got a better multimeter and confirmed it was a ferrite bead though, just white. So I had to solder it back on, lol.

Hopefully this can help someone else who runs into this problem - I couldn’t find much about it when I searched.

import time
import math
from . import bus

MAX31865_CONFIG_REG            = 0x00
MAX31865_RTDMSB_REG            = 0x01
MAX31865_RTDLSB_REG            = 0x02
MAX31865_HFAULTMSB_REG         = 0x03
MAX31865_HFAULTLSB_REG         = 0x04
MAX31865_LFAULTMSB_REG         = 0x05
MAX31865_LFAULTLSB_REG         = 0x06
MAX31865_FAULTSTAT_REG         = 0x07

MAX31865_CONFIG_BIAS           = 0x80
MAX31865_CONFIG_MODEAUTO       = 0x40
MAX31865_CONFIG_1SHOT          = 0x20
MAX31865_CONFIG_3WIRE          = 0x10
MAX31865_CONFIG_FAULTCLEAR     = 0x02
MAX31865_CONFIG_FILT50HZ       = 0x01

MAX31865_FAULT_HIGHTHRESH      = 0x80
MAX31865_FAULT_LOWTHRESH       = 0x40
MAX31865_FAULT_REFINLOW        = 0x20
MAX31865_FAULT_REFINHIGH       = 0x10
MAX31865_FAULT_RTDINLOW        = 0x08
MAX31865_FAULT_OVUV            = 0x04

MAX31865_ADC_MAX = 1<<15

Callendar-Van Dusen constants for platinum resistance thermometers (RTD)

CVD_A = 3.9083e-3
CVD_B = -5.775e-7

class MAX31865(SensorBase):
    def _init_(self, config):
    self.rtd_nominal_r = config.getfloat(‘rtd_nominal_r’, 100., above=0.)
    self.rtd_reference_r = config.getfloat(‘rtd_reference_r’, 430., above=0.)
    self.adc_to_resist_div_nominal = self.rtd_reference_r / float(MAX31865_ADC_MAX) / self.rtd_nominal_r

    # Call base init with build_spi_init
    init_config = self.build_spi_init(config)
    self.config_reg = init_config
    SensorBase.\__init_\_(self, config, "MAX31865", init_config)

    # Then enable VBIAS + auto-convert
    time.sleep(0.2)
    self.spi.spi_send(\[MAX31865_CONFIG_REG | 0x80,
                       MAX31865_CONFIG_BIAS | MAX31865_CONFIG_MODEAUTO\])
    time.sleep(0.05)  # small settle
    # Store printer reference for fault handling
    self.\_printer = config.get_printer()

def build_spi_init(self, config):
    # Build the standard init array for SPI
    # Bias OFF initially; auto-convert OFF; filter 50Hz; 2-wire or 3-wire from config
    config_val = MAX31865_CONFIG_FILT50HZ
    wires = config.getint('spi_wires', 2)  # default 2-wire
    if wires == 3:
         config_val |= MAX31865_CONFIG_3WIRE
    return \[MAX31865_CONFIG_REG | 0x80, config_val\]

def read_register(self, reg, num_bytes):
    """Read num_bytes from register reg."""
    cmd = \[reg & 0x7F\] + \[0x00\] \* num_bytes  # Clear MSB for read
    response = self.spi.spi_transfer(cmd)
    return response\[1:\]  # Skip the first byte (echo of command)

def read_fault_register(self):
    """Read and decode the fault status register."""
    fault_bytes = self.read_register(MAX31865_FAULTSTAT_REG, 1)
    fault = fault_bytes\[0\]
    self.dbg(f"Fault register: 0x{fault:02X}")

    if fault & 0x80:
        self.dbg("  - RTD High Threshold")
    if fault & 0x40:
        self.dbg("  - RTD Low Threshold")
    if fault & 0x20:
        self.dbg("  - REFIN- > 0.85 x VBIAS (FORCE- open)")
    if fault & 0x10:
        self.dbg("  - REFIN- < 0.85 x VBIAS (FORCE- open)")
    if fault & 0x08:
        self.dbg("  - RTDIN- < 0.85 x VBIAS (FORCE- open)")
    if fault & 0x04:
        self.dbg("  - Overvoltage/Undervoltage fault")

    return fault

def handle_fault(self, adc, fault):
    """Called by SensorBase when fault is detected."""
    if fault & 0x80:
        self.report_fault("MAX31865 RTD High Threshold")
    if fault & 0x40:
        self.report_fault("MAX31865 RTD Low Threshold")
    if fault & 0x20:
        self.report_fault("MAX31865 REFIN- > 0.85 x VBIAS (FORCE- open)")
    if fault & 0x10:
        self.report_fault("MAX31865 REFIN- < 0.85 x VBIAS (FORCE- open)")
    if fault & 0x08:
        self.report_fault("MAX31865 RTDIN- < 0.85 x VBIAS (FORCE- open)")
    if fault & 0x04:
        self.report_fault("MAX31865 Overvoltage/Undervoltage fault")

    # Attempt to clear the fault (from original)
    self.spi.spi_send(self.config_reg)


def calc_temp(self, adc_raw):
    """Convert raw ADC reading to temperature in Celsius."""
    # ADC needs empirical correction factor of /20
    adc_corrected = adc_raw / 2

    # Calculate resistance: R = (ADC \* R_ref) / 32768
    R_rtd = (adc_corrected \* self.rtd_reference_r) / 32768.0

    #self.dbg(f"ADC raw: {adc_raw}, ADC corrected: {adc_corrected:.1f}")
    #self.dbg(f"Resistance: {R_rtd:.2f} ohms (nominal: {self.rtd_nominal_r} ohms)")

    # Callendar-Van Dusen equation
    try:
        temp_c = (-CVD_A + math.sqrt(CVD_A\*\*2 - 4 \* CVD_B \* (1 - R_rtd / self.rtd_nominal_r))) / (2 \* CVD_B)
    except ValueError:
        #self.dbg(f"ERROR: Invalid resistance {R_rtd:.2f} ohms")
        temp_c = 0.0

    return temp_c

def calc_adc(self, temp):
    """
    Convert temperature to raw ADC value for min/max checking.
    Uses inverse Callendar-Van Dusen formula.
    """
    # Calculate resistance at this temperature
    R_rtd = self.rtd_nominal_r \* (1 + CVD_A \* temp + CVD_B \* temp\*\*2)

    # Calculate ADC value: ADC = (R \* 32768) / R_ref
    adc_corrected = (R_rtd \* 32768.0) / self.rtd_reference_r

    # Apply empirical correction factor
    adc_raw = adc_corrected \* 2

    return max(0, min(0xffff, int(adc_raw)))


def read_temp(self):
    # Clear faults + keep VBIAS + auto-convert
    self.spi.spi_send(\[
        MAX31865_CONFIG_REG | 0x80,
        MAX31865_CONFIG_BIAS |
        MAX31865_CONFIG_MODEAUTO |
        MAX31865_CONFIG_FAULTCLEAR
    \])
    time.sleep(0.01)

    # Correct raw read
    adc_raw_bytes = self.read_register(MAX31865_RTDMSB_REG, 2)

    #DEBUG PRINTS
    #self.dbg(f"Raw bytes: {adc_raw_bytes}")

    adc_raw = ((adc_raw_bytes\[0\] << 8) | adc_raw_bytes\[1\]) >> 1

    #DEBUG PRINTS
    #self.dbg(f"ADC raw: {adc_raw}")

    temp_c = self.calc_temp(adc_raw)

    #DEBUG PRINT
    #self.dbg(f"Temp C: {temp_c:.2f}")

    return temp_c

#def dbg(self, msg):
    #logging.info("=" \* 50)
    #logging.info("\[MAX31865 DEBUG\] " + str(msg))
    #logging.info ("=" \* 50)