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
+20 -2
View File
@@ -4,7 +4,7 @@ from components.actor.hendiCtrl import HendiCtrl
class HeaterHendi(AHeater):
def __init__(self, params):
AHeater.__init__(self)
AHeater.__init__(self, simulated=False)
self.hendi = HendiCtrl(params['port'], params['speed'])
self.power_set = 0
self.power_eff = 0
@@ -20,17 +20,35 @@ class HeaterHendi(AHeater):
caps = self.hendi.getCapabilties()
return [0] + caps['pwr_list']
def connect(self):
ok = self.hendi.connect()
self.connected = ok
self.firmware_version = self.hendi.sw_ver if ok else None
return ok
def disconnect(self):
self.hendi.disconnect()
self.connected = False
self.firmware_version = None
def activate(self, enable):
if not self.connected:
return
if enable:
self.hendi.remoteEnable(1)
else:
self.hendi.setSwitch(0)
def is_activated(self):
if not self.connected:
return False
s = self.hendi.isRemoteEnable()
return '1' in s
def process(self):
if not self.connected:
self.power_eff = 0
return
power = self.power_set
if power == 0:
self.hendi.setSwitch(0)
@@ -46,4 +64,4 @@ class HeaterHendi(AHeater):
return self.power_eff
def close(self):
self.hendi.close()
self.disconnect()
+38 -10
View File
@@ -12,15 +12,18 @@ class HendiException(Exception):
class HendiCtrl:
def __init__(self, port, speed, debug=False):
self.debug = debug
self.ser = serial.Serial(port, speed)
try:
self.ser.open()
except:
self.ser.close()
self.ser.open()
self.ser.timeout = 1.000
self.prompt = b':'
self.sw_id = None
self.sw_ver = None
# Deferred-open pyserial idiom: the port isn't actually opened until
# connect() is called, so a missing/unplugged device doesn't crash
# the whole server at construction time (see components/actor/
# heater_hendi.py's HeaterHendi.connect()).
self.ser = serial.Serial()
self.ser.port = port
self.ser.baudrate = speed
self.ser.timeout = 1.000
powers = [500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000, 2100, 2200, 2300, 2400, 2500, 2600, 2700, 2800, 2900, 3000, 3100, 3200, 3300,3400, 3500]
digits = [4092, 3840, 3700, 3440, 3300, 3060, 3000, 2930, 2860, 2780, 2640, 2510, 2430, 2360, 2190, 2130, 2030, 1980, 1880, 1730, 1630, 1560, 1480, 1400, 1240, 1150, 1150, 1080, 920, 740, 670]
@@ -36,13 +39,28 @@ class HendiCtrl:
self.poly_w2d = np.polyfit(powers, digits, 5)
self.poly_d2w = np.polyfit(digits, powers, 5)
def is_connected(self):
return self.ser.is_open
def connect(self):
if self.ser.is_open:
return True
try:
self.ser.open()
except serial.SerialException as e:
print(f"HendiCtrl: connect failed: {e}")
return False
# The port itself is what "connected" means - a failed version query
# (e.g. the device sitting in its ungraceful-disconnect lockout, see
# docs/hendi_lockout_findings.md) doesn't make the link any less
# open, so it's reported but doesn't fail connect() overall.
try:
self.sw_id = self.getSoftwareIdentifier()
self.sw_ver = self.getSoftwareVersion()
print(f"{self.sw_id}, F/W-Version: {self.sw_ver}")
except HendiException:
print("HendiCtrl: Hendi not found")
return True
@contextmanager
def remote_open(self):
@@ -191,5 +209,15 @@ class HendiCtrl:
def getPowerWatts(self):
return int(self.toWatts(self.getPowerDigits()) + 0.5)
def disconnect(self):
if self.ser.is_open:
try:
self.remoteEnable(False)
except HendiException:
pass
self.ser.close()
self.sw_id = None
self.sw_ver = None
def close(self):
self.remoteEnable(False)
self.disconnect()
+42 -6
View File
@@ -50,17 +50,50 @@ class Varid(enum.Enum):
class Pololu1376:
def __init__(self, serial_port):
# Deferred-open pyserial idiom: serial_port is constructed with no
# port set (see components/actor/stirrerpololu1376.py), so it isn't
# actually opened until connect() is called - a missing/unplugged
# device then doesn't crash the whole server at construction time.
self.ser = serial_port
self.ser.set_output_flow_control(False)
self.firmware_version = None
def is_connected(self):
return self.ser.is_open
def connect(self):
if self.ser.is_open:
return True
try:
self.ser.open()
except serial.SerialException:
try:
self.ser.close()
self.ser.open()
except serial.SerialException as e:
print(f"Pololu1376: connect failed: {e}")
return False
# set_output_flow_control() requires the port to already be open -
# can only happen here, after open() above, not at construction time.
self.ser.set_output_flow_control(False)
try:
self.stop()
self.firmware_version = self.get_firmware_version()
print("Pololu1376 F/W-Version:", self.firmware_version)
return True
except Exception as e:
print(f"Pololu1376: connect failed: {e}")
self.ser.close()
self.ser.open()
self.firmware_version = None
return False
self.stop()
print("Pololu1376 F/W-Version:", self.get_firmware_version())
def disconnect(self):
if self.ser.is_open:
try:
self.stop()
except Exception:
pass
self.ser.close()
self.firmware_version = None
def send(self, data):
self.ser.write((data + '\r\n').encode())
@@ -126,9 +159,12 @@ class Pololu1376:
if __name__ == '__main__':
ser = serial.Serial("/dev/ttyACM0", 115200)
ser = serial.Serial()
ser.port = "/dev/ttyACM0"
ser.baudrate = 115200
drv = Pololu1376(serial_port=ser)
drv.connect()
drv.go()
time.sleep(1)
+25 -3
View File
@@ -10,12 +10,27 @@ class StirrerPololu1376(AStirrer):
return "Pololu1376"
def __init__(self, dt, params):
AStirrer.__init__(self, dt)
AStirrer.__init__(self, dt, simulated=False)
ser = serial.Serial(params['port'], params['speed'])
ser.set_output_flow_control(False)
# Deferred-open pyserial idiom - see Pololu1376's own constructor
# comment: connect() is what actually opens params['port'] (and
# sets output flow control, which itself requires an open port).
ser = serial.Serial()
ser.port = params['port']
ser.baudrate = params['speed']
self.drv = Pololu1376(ser)
def connect(self):
ok = self.drv.connect()
self.connected = ok
self.firmware_version = self.drv.firmware_version if ok else None
return ok
def disconnect(self):
self.drv.disconnect()
self.connected = False
self.firmware_version = None
@contextmanager
def remote_open(self):
try:
@@ -30,6 +45,8 @@ class StirrerPololu1376(AStirrer):
return 0
def activate(self, enable):
if not self.connected:
return
print("activate {}".format(enable))
if enable:
self.drv.go()
@@ -37,10 +54,14 @@ class StirrerPololu1376(AStirrer):
self.drv.stop()
def _on_set_speed(self, speed):
if not self.connected:
return
self.drv.motor_forward(speed)
print("Set speed to {} %".format(speed))
def _on_process(self):
if not self.connected:
return
status = self.drv.get_variable(Varid.STATUS)
limit_status = self.drv.get_variable(Varid.STATUS_LIMIT_STATUS)
if status & 0x01 == 0x01:
@@ -51,6 +72,7 @@ class StirrerPololu1376(AStirrer):
if __name__ == '__main__':
s = StirrerPololu1376(dt=1.0, params={'port': "/dev/ttyACM0", 'speed': 115200})
s.connect()
with s.remote_open():
+4 -4
View File
@@ -1,11 +1,11 @@
import abc
from utils.value import AttributeChange
from components.connectable import Connectable
from contextlib import contextmanager
class AHeater(AttributeChange):
def __init__(self):
AttributeChange.__init__(self)
class AHeater(Connectable):
def __init__(self, simulated=True):
Connectable.__init__(self, simulated)
self.is_active = False
@abc.abstractmethod
+4 -4
View File
@@ -1,11 +1,11 @@
import abc
from utils.value import AttributeChange
from components.connectable import Connectable
from contextlib import contextmanager
class AStirrer(AttributeChange):
def __init__(self, dt):
AttributeChange.__init__(self)
class AStirrer(Connectable):
def __init__(self, dt, simulated=True):
Connectable.__init__(self, simulated)
self.dt = dt
self.speed = 0
self.cycleTime = 1
+22
View File
@@ -0,0 +1,22 @@
from utils.value import AttributeChange
class Connectable(AttributeChange):
"""Observable hardware-connection state shared by AHeater/AStirrer.
Simulated devices are always connected and report no firmware version;
real (serial) devices start disconnected and only flip once connect()
actually reaches the hardware.
"""
def __init__(self, simulated):
AttributeChange.__init__(self)
self.simulated = simulated
self.connected = simulated
self.firmware_version = None
def connect(self):
return self.connected
def disconnect(self):
pass