AttributeChange (the common base of every ATask and component ABC) now sets self.log = logging.getLogger(type(self).__name__), so components no longer need to hand-type their own name into each message. Wired logging.basicConfig() in server/brewpi.py with a bare "%(name)s: %(message)s" formatter - Tee (see prior commit) still supplies the "<date>T<time>:" prefix, so lines read "<date>T<time>:<component>: <message>" without double-stamping, and third-party loggers (websockets, asyncio) now get the same formatting for free. Converted the print() call sites that were standing in for this in tasks/ and components/ (leaving __main__ demo blocks and explicit debug-dump helpers alone).
240 lines
6.5 KiB
Python
Executable File
240 lines
6.5 KiB
Python
Executable File
#!/usr/bin/python3
|
|
|
|
import logging
|
|
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.log = logging.getLogger(type(self).__name__)
|
|
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:
|
|
self.log.error(f"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()
|
|
self.log.info(f"{self.sw_id}, F/W-Version: {self.sw_ver}")
|
|
except HendiException:
|
|
self.log.warning("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):
|
|
self.log.info("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:
|
|
self.log.debug(f"-> {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:
|
|
self.log.debug(f"<- 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 Exception:
|
|
# Not just HendiException (a protocol-level NAK) - a genuine
|
|
# unplug fails deep inside pyserial itself (termios.error/
|
|
# OSError from flushInput()/readline() on a dead fd), and
|
|
# disconnect() must never let that escape: every caller here
|
|
# (HeaterHendi.process()/activate()'s own except handlers,
|
|
# AHeater.open()'s context-manager exit) calls disconnect()
|
|
# specifically to recover from a comm failure, so if this
|
|
# raises too it propagates all the way out of HeaterTask's
|
|
# on_process() and permanently kills that task's loop - the
|
|
# device then never gets process()'d again even after a
|
|
# later successful reconnect.
|
|
pass
|
|
try:
|
|
self.ser.close()
|
|
except Exception:
|
|
pass
|
|
self.sw_id = None
|
|
self.sw_ver = None
|
|
|
|
def close(self):
|
|
self.disconnect()
|