fix: temp sensor read failures could crash startup or permanently kill the task

Same bug class as the earlier heater fix: TempSensorTask.on_process()
called self.sensor.temperature() every tick with no exception handling
at all, and the constructor even called it once synchronously at
startup to prime the value. A read failure there would either crash
the whole server before the event loop started, or permanently kill
TempSensorTask's coroutine mid-run - nothing restarts a dead ATask, so
the sensor would never be read again for the rest of the process's
life.

TempSensor_max31865.temperature() now catches read failures and calls
a new reopen() (closes/reopens the spidev handle - the closest
equivalent to unplug/replug for a bus peripheral) instead of raising,
holding the last-known-good reading meanwhile. reopen() itself never
raises either, learned from HendiCtrl.disconnect() previously letting
a failure escape the same way. Belt-and-suspenders guards added at the
TempSensorTask level too, matching HeaterTask/StirrerTask.

Verified against a fake spidev: a read failure no longer raises,
reopen() is attempted automatically, and readings resume once the bus
responds again - even when reopen() itself also fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
This commit is contained in:
2026-07-03 21:45:21 +02:00
co-authored by Claude Sonnet 5
parent b6b79adff8
commit 4b9a144b62
2 changed files with 72 additions and 6 deletions
+54 -2
View File
@@ -10,14 +10,54 @@ class TempSensor_max31865(ATemperatureSensor):
def __init__(self, temp_offset=0, **kwargs): def __init__(self, temp_offset=0, **kwargs):
ATemperatureSensor.__init__(self) ATemperatureSensor.__init__(self)
# Open SPI bus self.temp_offset = temp_offset
# Set before the first read attempt so a read failure even on the
# very first call (temperature() below, or TempSensorTask's own
# priming call) has a well-defined last-known-good value to fall
# back to rather than raising AttributeError.
self.temp = None
self.spi = spidev.SpiDev() self.spi = spidev.SpiDev()
try:
self._open_spi()
except Exception as e:
# Don't crash the whole server if the SPI bus is momentarily
# busy/unavailable at startup (e.g. right after a Pi reboot) -
# temperature()'s own except handler already retries via
# reopen() on the next tick, same recovery path as a later
# runtime failure.
print(f"TempSensor_max31865: could not open SPI at startup: {e}")
def _open_spi(self):
self.spi.open(0, 0) self.spi.open(0, 0)
self.spi.max_speed_hz = 50000 self.spi.max_speed_hz = 50000
self.spi.mode = 0b01 self.spi.mode = 0b01
self.temp_offset = temp_offset
self.write_reg(0x00, 0xA3) self.write_reg(0x00, 0xA3)
def reopen(self):
"""Closes and reopens the SPI handle - recovers from a wedged bus/
chip-select state (e.g. left mid-transaction by some other fault)
without needing a full process or Pi reboot. There's no serial-
style connect/disconnect concept for a bus peripheral like this
one, so this is the closest equivalent - called automatically by
temperature() below on a read failure.
Never raises - it's itself the recovery path called from
temperature()'s except handler, and if it raised too that would
escape temperature() entirely (same class of bug as HendiCtrl.
disconnect() previously letting a comm failure escape and
permanently kill HeaterTask's coroutine - see tasks/heater.py's
on_process()). A genuinely still-broken bus just leaves self.temp
at its last-known-good value, logged, and gets retried again next
tick."""
try:
self.spi.close()
except Exception:
pass
try:
self._open_spi()
except Exception as e:
print(f"TempSensor_max31865: reopen failed: {e}")
def read_reg(self, addr): def read_reg(self, addr):
reg = self.spi.xfer([addr, 0xFF]) reg = self.spi.xfer([addr, 0xFF])
@@ -46,8 +86,20 @@ class TempSensor_max31865(ATemperatureSensor):
def temperature(self): def temperature(self):
try:
digits = self.read_digits() digits = self.read_digits()
self.temp = self.temp_offset + self.to_temperature(digits) self.temp = self.temp_offset + self.to_temperature(digits)
except Exception as e:
# Holds the last-known-good self.temp rather than raising -
# TempSensorTask.on_process() calls this every tick with no
# guard of its own (mirrors HeaterHendi.process()'s pattern of
# self-healing at the actor level rather than letting a comm
# failure escape and permanently kill the task's coroutine -
# nothing restarts a dead ATask). reopen() gives the SPI bus a
# chance to recover from a wedged state without a full process/
# Pi reboot - see docs/pi_deployment_notes.md.
print(f"TempSensor_max31865: comm error reading sensor, reopening SPI: {e}")
self.reopen()
return self.temp return self.temp
@staticmethod @staticmethod
+14
View File
@@ -16,7 +16,15 @@ class TempSensorTask(ATask):
# which would otherwise fire on_temp_changed() (and its # which would otherwise fire on_temp_changed() (and its
# asyncio.create_task()) before the event loop is running, since # asyncio.create_task()) before the event loop is running, since
# all tasks are built synchronously at module level in brewpi.py. # all tasks are built synchronously at module level in brewpi.py.
# Guarded even though TempSensor_max31865.temperature() already
# catches its own read/reopen failures - belt-and-suspenders
# against a still-unanticipated exception crashing server startup
# entirely (same lesson as HeaterTask/StirrerTask's own outer
# guards - see tasks/heater.py's on_process()).
try:
self.sensor.temperature() self.sensor.temperature()
except Exception as e:
print(f"TempSensorTask: comm error priming sensor: {e}")
self.sensor.set_on_changed("temp", ChangedFloat(self.on_temp_changed, prec=1).set) self.sensor.set_on_changed("temp", ChangedFloat(self.on_temp_changed, prec=1).set)
def on_temp_changed(self, value): def on_temp_changed(self, value):
@@ -29,6 +37,12 @@ class TempSensorTask(ATask):
await self.msg_handler.send(data) await self.msg_handler.send(data)
async def on_process(self): async def on_process(self):
# Guarded for the same reason as the priming call above - nothing
# restarts a dead ATask, so an exception escaping this loop would
# permanently stop this sensor from ever being read again.
while True: while True:
try:
self.sensor.temperature() self.sensor.temperature()
except Exception as e:
print(f"TempSensorTask: comm error reading sensor: {e}")
await asyncio.sleep(self.interval) await asyncio.sleep(self.interval)