feat: add connect/disconnect status for real heater/stirrer hardware

Heater and Stirrer can be real serial hardware (hendi, Pololu1376), but
there was no connection concept at all - the constructors opened the
port and crashed the whole server if the device was missing, with no
way to see connection status or firmware version and no way to
reconnect without a restart.

Adds an observable Connectable mixin (components/connectable.py) shared
by AHeater/AStirrer; real devices defer opening the serial port to an
explicit connect(), auto-connect on server startup, and surface
Connected/FirmwareVersion/Simulated plus manual Connect/Disconnect over
the web GUI. Heating/stirring and Sud Start are all gated on connection
state, and a disconnect mid-brew force-stops the run via the same path
as a manual Stop.

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 19:18:59 +02:00
co-authored by Claude Sonnet 5
parent 698c019581
commit f20e617d81
15 changed files with 369 additions and 39 deletions
+43 -1
View File
@@ -18,12 +18,29 @@ class HeaterTask(ATask):
self.closed_loop = True
self._on_closed_loop_changed = None
self.pulse_counter = 0
self._on_connected_changed = None
device.set_on_changed('power_eff', ChangedInteger(self.on_changed_power).set)
device.set_on_changed('connected', self.on_connected_changed)
device.set_on_changed('firmware_version', self.on_firmware_version_changed)
self.power_set_changed = ChangedInteger(self.on_changed_power_set).set
def set_on_closed_loop_changed(self, callback):
self._on_closed_loop_changed = callback
def set_on_connected_changed(self, callback):
"""Registers a callback(connected: bool) invoked whenever the
device's connection state changes - lets server/brewpi.py wire this
into SudTask's start-gating/mid-brew auto-stop."""
self._on_connected_changed = callback
def on_connected_changed(self, value):
asyncio.create_task(self.send({'Connected': value}))
if self._on_connected_changed:
self._on_connected_changed(value)
def on_firmware_version_changed(self, value):
asyncio.create_task(self.send({'FirmwareVersion': value}))
def shutdown(self):
"""Called when the brew ends (DONE/IDLE). Switches to open-loop at
power 0 and disables TC, mirroring what a Stop press should do."""
@@ -60,6 +77,12 @@ class HeaterTask(ATask):
if self._on_closed_loop_changed:
self._on_closed_loop_changed(self.closed_loop)
await self.send({'ClosedLoop': self.closed_loop})
elif pair[0] == 'Connect':
if self.device.connect():
self.device.activate(True)
elif pair[0] == 'Disconnect':
self.device.activate(False)
self.device.disconnect()
elif 'Power' in pair[0]:
self.power_soll = pair[1]
@@ -82,6 +105,7 @@ class HeaterTask(ATask):
async def on_process(self):
await self.send({'Capabilities': {'Power': {'Min': 0, 'Max': self.device.get_power_max()}}})
await self.send({'Simulated': self.device.simulated})
await self.send({'ClosedLoop': self.closed_loop})
if self._on_closed_loop_changed:
self._on_closed_loop_changed(self.closed_loop)
@@ -89,6 +113,20 @@ class HeaterTask(ATask):
pulse_period_s = 10
pulse_period_count = pulse_period_s/self.interval
# Auto-connect on startup - replaces what used to happen implicitly
# in the device's own constructor; a missing/unplugged device just
# stays disconnected instead of crashing the server (see
# components/actor/heater_hendi.py's connect()).
self.device.connect()
# Explicit initial push, same as Capabilities/Simulated/ClosedLoop
# above - a simulated device's connect() is a no-op that never
# reassigns self.connected (see components/connectable.py), so its
# set_on_changed('connected', ...) observer would otherwise never
# fire and a freshly-subscribed client would never learn it's
# connected.
await self.send({'Connected': self.device.connected})
await self.send({'FirmwareVersion': self.device.firmware_version})
with self.device.open():
while True:
# Closed-loop: TC has full control. Open-loop: direct manual power only.
@@ -113,7 +151,11 @@ class HeaterTask(ATask):
elif self.pulse_counter < on_count:
self.device.set_power(power_high)
self.device.process()
try:
self.device.process()
except Exception as e:
print(f"HeaterTask: comm error, marking disconnected: {e}")
self.device.disconnect()
await asyncio.sleep(self.interval)