Skip to content

Prevent nuisance shutdowns from transient I2C NACK errors#39

Merged
thetic merged 15 commits into
mainfrom
sins
May 23, 2026
Merged

Prevent nuisance shutdowns from transient I2C NACK errors#39
thetic merged 15 commits into
mainfrom
sins

Conversation

@thetic

@thetic thetic commented May 2, 2026

Copy link
Copy Markdown
Owner

Summary

  • Intercepts invoke_shutdown() calls from Klipper's bus.py:i2c_transfer() by replacing i2c_transfer_cmd with a wrapper that raises command_error instead on non-SUCCESS I2C status
  • Patches the SGP40's own I2C object and any I2C-based ref sensors (e.g. BME280)
  • Wraps only the I2C operations in _handle_step in a try/except; on failure, logs the error and backs off for 5× the sampling interval rather than crashing the printer

Requirements

MCU firmware must be compiled and flashed from Klipper v0.13.0-159 or newer. A stale MCU firmware build may not support the i2c_transfer command that this patch relies on, and the NACK interception will not take effect.

Test plan

  • Verify normal operation: VOC readings update as expected with no errors
  • Simulate transient NACK (e.g. briefly disconnect sensor during operation): Klipper should log the error and resume readings after backoff, not shut down
  • Verify startup failure still crashes Klipper (sensor absent/unresponsive at boot should be a hard failure)
  • Verify ref sensor (BME280) NACK during operation is also handled gracefully
  • Confirm no regression when ref_temp_sensor and ref_humidity_sensor point to the same sensor (double-patch guard)

thetic added 12 commits April 28, 2026 20:25
bus.py's i2c_transfer() calls invoke_shutdown() on any non-SUCCESS I2C
status, which crashes the printer on transient NACK errors from the SGP40.
This is too aggressive for a non-critical VOC sensor.

Changes:
- Add _patch_i2c() to replace invoke_shutdown() with command_error on this
  device's MCU_I2C instance, scoped only to the SGP40 (not other devices)
- Add _read() retry loop: up to 5 attempts with 500ms delay between each,
  catching command_error — mirrors the SHT3x pattern from Klipper PR #6975
  and aligns with the exception type established in Klipper PR #7206
- Wrap _handle_step() in try/except: on failure, log the error, reset
  measuring state, and reschedule 5 intervals later rather than stopping
- Use retry=False on i2c_read() to prevent MCU-level serial retries from
  triggering a second NACK on an already-consumed SGP40 read buffer
- Rename _read(len=) to _read(count=) to avoid shadowing the builtin
Extend the invoke_shutdown -> command_error patch to any ref sensor that
exposes an i2c attribute. This prevents a BME280 configured as
ref_temp_sensor or ref_humidity_sensor from triggering a printer shutdown
on a transient I2C NACK, since it shares the same bus and the same glitch
susceptibility as the SGP40.

Move the patch call into _check_ref_sensor, which already holds the sensor
object, and generalize _patch_i2c() to accept any MCU_I2C instance.
The I2C error handling on ref_temp_sensor and ref_humidity_sensor is
modified to raise instead of invoke_shutdown on NACK errors. Document
that this makes them unsafe to use for heater control.
Kalico's MCU_I2C has no i2c_transfer_cmd attribute and its i2c_read()
takes no retry parameter, but it also doesn't call invoke_shutdown() on
I2C errors so the patch is a no-op there anyway.

- Use getattr() guard in _patch_i2c() to handle missing i2c_transfer_cmd
- Drop retry=False from i2c_read() call; with _patch_i2c() in place it
  was no longer load-bearing for correctness on Klipper, and it broke
  Kalico's narrower i2c_read() signature
- Restore Kalico minimum version in README
Patching i2c_transfer as an instance method proved unreliable — the
original bus.py method was still being called in practice. Replacing
i2c_transfer_cmd (a plain data attribute) sidesteps Python's method
descriptor resolution entirely, so command_error is raised before
invoke_shutdown is ever reached.

Kalico support is removed: its MCU firmware calls shutdown() directly
on any I2C NACK (i2ccmds.c i2c_shutdown_on_err), which cannot be
intercepted at the Python level.
If the same I2C sensor is configured as both ref_temp_sensor and
ref_humidity_sensor, _patch_i2c was called twice on the same i2c object,
wrapping _SafeTransferCmd inside another _SafeTransferCmd. Moving the class
to module level allows isinstance() to detect an already-patched object and
return early.
The retry loop called reactor.pause() up to 5 times with 500ms waits,
potentially stalling the reactor dispatch loop for 2.5 seconds. The
except-Exception handler in _handle_step already provides retry semantics
by rescheduling at 5x sampling_interval on any failure, so the in-function
retries were redundant.
The except block covered the entire step — including i2c_write, temperature
and humidity sensor lookups — not just the read. "Error reading data" was
misleading when the failure came from elsewhere.
The measurement loop tolerates transient NACKs via _handle_step's exception
handler, which could mislead a reader into thinking _init_sgp40 should be
similarly lenient. The comment clarifies that startup failure is deliberate:
a sensor that won't respond at connect time is a real error.
The safety constraint was only visible inside the config comment block.
A user skimming the README would miss it entirely. The admonition places
it prominently in the Configuration section where it will be seen.
The calibration state assignment, reference sensor lookups, and cmd build
are all in-memory and cannot raise command_error. Keeping them inside the
try block meant bugs in that code would be caught and logged as I2C errors
rather than surfacing cleanly. Moving them before the try leaves only the
SGP40 read and write inside the guarded block.
@ndanyluk

ndanyluk commented May 3, 2026

Copy link
Copy Markdown

Hi @thetic I like the new approach. One thing I've observed doing this the quick and dirty way (just commenting out the invoke_shutdown command inside of bus.py) is that it works perfectly for SGP40 with your code loop as it is, but BMEx80 appears to have some special handling.

The BMEx80 code from what I can tell treats any failure as a "oops, fail gracefully and stop updating until restart" event. We may need to re-init the BME280 when we hit this event otherwise the temp/humidity compensation gets locked to whatever it was when the BME280 fails.

From my testing, it seems like most of the I2C NACKs are benign (both START_NACK, START_READ_NACK, etc), but a NACK on SGP40 can sometimes cause a bus timeout on BME280.

Relevant test over on klipper discourse

I'll test this out when I can, probably not till Thursday though

BME280 permanently stops its sample timer when it catches an I2C error.
Track ref sensors with sample timers and reschedule them each cycle while
their reported temp is 0, allowing recovery without a printer restart.
@thetic
thetic marked this pull request as ready for review May 13, 2026 04:26
@thetic

thetic commented May 13, 2026

Copy link
Copy Markdown
Owner Author

I've been running this for about two weeks now without a nuisance restart and without seeing either the sgp40s or bme280s stop responding. my current uptime is about a week.

@ndanyluk

Copy link
Copy Markdown

Hey @thetic that's great to hear! I haven't gotten to test but will flash today and see.

One question about the ref sensor code - I've seen when the BME280 gets stuck (after SGP40 failure causes an I2C Bus timeout) it's temp doesn't go to 0, it locks to whatever it was at time of failure (i.e. it the print will be over and back to 25C and the bme280 reports 56C. Your code appears to only look at 0 value sensors so does it catch this fail condition?

@thetic

thetic commented May 16, 2026

Copy link
Copy Markdown
Owner Author

The == 0.0 check should cover it for our implementation. Looking at BME280's _sample_bme280: the only code path that stops the timer (return reactor.NEVER) always sets self.temp = 0.0 first. There's no path where the timer stops with a stale non-zero value.

The "locked at last reading" behavior you observed is almost certainly specific to commenting out invoke_shutdown in bus.py directly. With that approach, a NACK doesn't raise an exception, the I2C read silently returns whatever's in the transfer buffer, _sample_bme280 processes it without hitting the except block, and the timer keeps running with corrupted data.

With the _patch_i2c approach in this PR, a NACK raises command_error, which propagates through read_register and is caught by BME280's own except Exception. That sets temp = 0.0 and returns NEVER, exactly the condition we reschedule on. I haven't seen this in two weeks of runtime, but the code path should handle it correctly.

* origin/main:
  Add cross-sensor variance floor synchronization via sync_with
@thetic

thetic commented May 17, 2026

Copy link
Copy Markdown
Owner Author

Quick update: I can now confirm the BME280 recovery works in practice. After the error bursts I see in my logs, successive queries to the BME280s return different values, so they're not locked to a stale reading. The == 0.0 check catches the stopped sensor, the rescheduler kicks the timer back to life, and it picks up fresh samples. Closes the loop on your question.

@thetic
thetic merged commit d076640 into main May 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants