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
224 lines
5.7 KiB
Python
Executable File
224 lines
5.7 KiB
Python
Executable File
#!/usr/bin/python3
|
|
|
|
import time
|
|
import serial
|
|
import sys
|
|
import numpy as np
|
|
from contextlib import contextmanager
|
|
|
|
class HendiException(Exception):
|
|
pass
|
|
|
|
class HendiCtrl:
|
|
def __init__(self, port, speed, debug=False):
|
|
self.debug = debug
|
|
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]
|
|
self.caps = {
|
|
"pwr_watts_min" : powers[0],
|
|
"pwr_watts_max" : powers[-1],
|
|
"pwr_list" : powers,
|
|
"pwr_watts_step" : 100,
|
|
"pwr_digits_min" : digits[0],
|
|
"pwr_digits_max" : digits[-1],
|
|
"digit_list" : digits
|
|
}
|
|
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):
|
|
try:
|
|
self.remoteEnable(True)
|
|
yield None
|
|
finally:
|
|
self.remoteEnable(False)
|
|
|
|
def toDigits(self, power_watts):
|
|
return np.polyval(self.poly_w2d, float(power_watts))
|
|
|
|
def toWatts(self, power_digits):
|
|
return np.polyval(self.poly_d2w, float(power_digits))
|
|
|
|
def reset(self):
|
|
self.ser.dtr = True
|
|
self.ser.rts = False
|
|
time.sleep(0.01)
|
|
self.ser.dtr = True
|
|
self.ser.rts = True
|
|
|
|
def enter_bootloader(self):
|
|
self.ser.dtr = True
|
|
self.ser.rts = False
|
|
time.sleep(0.01)
|
|
self.ser.dtr = False
|
|
self.ser.rts = True
|
|
|
|
def firmware_update(self, filename):
|
|
print("Start firmware update")
|
|
self.enter_bootloader()
|
|
self.ser.readline()
|
|
|
|
self.ser.flushInput()
|
|
with open(filename, 'r') as fp:
|
|
line_count = 0
|
|
while (True):
|
|
s = fp.readline()
|
|
if s == '':
|
|
break
|
|
data = s.encode()
|
|
self.ser.write(data)
|
|
ack = self.ser.read()
|
|
sys.stdout.write(ack.decode('utf-8'))
|
|
sys.stdout.flush()
|
|
if line_count == 64:
|
|
line_count = 0
|
|
sys.stdout.write("\n")
|
|
|
|
line_count += 1
|
|
|
|
self.ser.readline()
|
|
self.ser.flushInput()
|
|
self.ser.write(b'q')
|
|
self.ser.flushOutput()
|
|
|
|
def __write(self, s):
|
|
self.ser.flushInput()
|
|
data = s.encode()
|
|
if self.debug:
|
|
print(f"HendiCtrl: -> {data!r}")
|
|
self.ser.write(data + b'\r')
|
|
|
|
def __read(self):
|
|
# Read line echo
|
|
echo = self.ser.readline()
|
|
|
|
# Read answer line
|
|
answer_raw = self.ser.readline()
|
|
if self.debug:
|
|
print(f"HendiCtrl: <- echo={echo!r} answer={answer_raw!r}")
|
|
answer = answer_raw.decode('utf-8').replace('\r', '').replace('\n', '')
|
|
if ':' in answer:
|
|
result = answer.split(':')
|
|
else:
|
|
result = answer
|
|
|
|
return result, echo, answer_raw
|
|
|
|
def cmd(self, req):
|
|
self.__write(req)
|
|
rsp, echo, answer_raw = self.__read()
|
|
if len(rsp) == 0:
|
|
raise HendiException(f"Communication error: req={req!r} echo={echo!r} answer={answer_raw!r}")
|
|
if "OK" not in rsp[0]:
|
|
raise HendiException(f"Communication error: req={req!r} echo={echo!r} answer={answer_raw!r}")
|
|
|
|
return rsp[1]
|
|
|
|
def getCapabilties(self):
|
|
return self.caps
|
|
|
|
def getInfo(self):
|
|
result = {
|
|
"sw_id" : self.getSoftwareIdentifier(),
|
|
"sw_ver" : self.getSoftwareVersion(),
|
|
'state' : self.getState(),
|
|
'pwr_digits' : self.getPowerDigits(),
|
|
'Switch_state' : self.getSwitch()
|
|
}
|
|
return result
|
|
|
|
def _debugEnable(self, enable):
|
|
return self.cmd("D" + str(int(enable)))
|
|
|
|
def _isDebugEnable(self):
|
|
return self.cmd("D?")
|
|
|
|
def getSoftwareIdentifier(self):
|
|
return self.cmd("I?")
|
|
|
|
def getSoftwareVersion(self):
|
|
return self.cmd("V?")
|
|
|
|
def getState(self):
|
|
return self.cmd("T?")
|
|
|
|
def remoteEnable(self, enable):
|
|
return self.cmd("R" + str(int(enable)))
|
|
|
|
def isRemoteEnable(self):
|
|
return '1' in self.cmd("R?")
|
|
|
|
def setPowerDigits(self, power_digits):
|
|
return self.cmd("P" + str(power_digits))
|
|
|
|
def getPowerDigits(self):
|
|
return self.cmd("P?")
|
|
|
|
def setSwitch(self, enable):
|
|
return self.cmd("S" + str(int(enable)))
|
|
|
|
def getSwitch(self):
|
|
return self.cmd("S?")
|
|
|
|
def setPowerWatts(self, power_watts):
|
|
pwr_digits_min = self.caps['pwr_digits_min']
|
|
pwr_digits_max = self.caps['pwr_digits_max']
|
|
max_digits = max(pwr_digits_max, pwr_digits_min)
|
|
min_digits = min(pwr_digits_max, pwr_digits_min)
|
|
pwr_digits = int(self.toDigits(power_watts))
|
|
pwr_digits = int(min(max_digits, max(min_digits, pwr_digits)))
|
|
return self.setPowerDigits(pwr_digits)
|
|
|
|
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.disconnect()
|