diff --git a/components/sensor/tempSensor_max31865.py b/components/sensor/tempSensor_max31865.py index 195efa1..eecf98b 100755 --- a/components/sensor/tempSensor_max31865.py +++ b/components/sensor/tempSensor_max31865.py @@ -10,14 +10,54 @@ class TempSensor_max31865(ATemperatureSensor): def __init__(self, temp_offset=0, **kwargs): 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() + 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.max_speed_hz = 50000 self.spi.mode = 0b01 - self.temp_offset = temp_offset 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): reg = self.spi.xfer([addr, 0xFF]) @@ -46,8 +86,20 @@ class TempSensor_max31865(ATemperatureSensor): def temperature(self): - digits = self.read_digits() - self.temp = self.temp_offset + self.to_temperature(digits) + try: + digits = self.read_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 @staticmethod diff --git a/tasks/tempsensor.py b/tasks/tempsensor.py index 62c0654..687485a 100644 --- a/tasks/tempsensor.py +++ b/tasks/tempsensor.py @@ -16,7 +16,15 @@ class TempSensorTask(ATask): # which would otherwise fire on_temp_changed() (and its # asyncio.create_task()) before the event loop is running, since # all tasks are built synchronously at module level in brewpi.py. - self.sensor.temperature() + # 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() + 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) def on_temp_changed(self, value): @@ -29,6 +37,12 @@ class TempSensorTask(ATask): await self.msg_handler.send(data) 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: - self.sensor.temperature() + try: + self.sensor.temperature() + except Exception as e: + print(f"TempSensorTask: comm error reading sensor: {e}") await asyncio.sleep(self.interval)