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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user