Add connect/disconnect status for real heater/stirrer hardware #3

Merged
jens merged 2 commits from observer_refactor into master 2026-07-03 19:26:18 +02:00
18 changed files with 381 additions and 45 deletions
+20 -2
View File
@@ -4,7 +4,7 @@ from components.actor.hendiCtrl import HendiCtrl
class HeaterHendi(AHeater): class HeaterHendi(AHeater):
def __init__(self, params): def __init__(self, params):
AHeater.__init__(self) AHeater.__init__(self, simulated=False)
self.hendi = HendiCtrl(params['port'], params['speed']) self.hendi = HendiCtrl(params['port'], params['speed'])
self.power_set = 0 self.power_set = 0
self.power_eff = 0 self.power_eff = 0
@@ -20,17 +20,35 @@ class HeaterHendi(AHeater):
caps = self.hendi.getCapabilties() caps = self.hendi.getCapabilties()
return [0] + caps['pwr_list'] 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): def activate(self, enable):
if not self.connected:
return
if enable: if enable:
self.hendi.remoteEnable(1) self.hendi.remoteEnable(1)
else: else:
self.hendi.setSwitch(0) self.hendi.setSwitch(0)
def is_activated(self): def is_activated(self):
if not self.connected:
return False
s = self.hendi.isRemoteEnable() s = self.hendi.isRemoteEnable()
return '1' in s return '1' in s
def process(self): def process(self):
if not self.connected:
self.power_eff = 0
return
power = self.power_set power = self.power_set
if power == 0: if power == 0:
self.hendi.setSwitch(0) self.hendi.setSwitch(0)
@@ -46,4 +64,4 @@ class HeaterHendi(AHeater):
return self.power_eff return self.power_eff
def close(self): def close(self):
self.hendi.close() self.disconnect()
+38 -10
View File
@@ -12,15 +12,18 @@ class HendiException(Exception):
class HendiCtrl: class HendiCtrl:
def __init__(self, port, speed, debug=False): def __init__(self, port, speed, debug=False):
self.debug = debug 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.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] 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] 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_w2d = np.polyfit(powers, digits, 5)
self.poly_d2w = np.polyfit(digits, powers, 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: try:
self.sw_id = self.getSoftwareIdentifier() self.sw_id = self.getSoftwareIdentifier()
self.sw_ver = self.getSoftwareVersion() self.sw_ver = self.getSoftwareVersion()
print(f"{self.sw_id}, F/W-Version: {self.sw_ver}") print(f"{self.sw_id}, F/W-Version: {self.sw_ver}")
except HendiException: except HendiException:
print("HendiCtrl: Hendi not found") print("HendiCtrl: Hendi not found")
return True
@contextmanager @contextmanager
def remote_open(self): def remote_open(self):
@@ -191,5 +209,15 @@ class HendiCtrl:
def getPowerWatts(self): def getPowerWatts(self):
return int(self.toWatts(self.getPowerDigits()) + 0.5) 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): def close(self):
self.remoteEnable(False) self.disconnect()
+42 -6
View File
@@ -50,17 +50,50 @@ class Varid(enum.Enum):
class Pololu1376: class Pololu1376:
def __init__(self, serial_port): 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 = 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: try:
self.ser.open() self.ser.open()
except serial.SerialException: 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.close()
self.ser.open() self.firmware_version = None
return False
self.stop() def disconnect(self):
if self.ser.is_open:
print("Pololu1376 F/W-Version:", self.get_firmware_version()) try:
self.stop()
except Exception:
pass
self.ser.close()
self.firmware_version = None
def send(self, data): def send(self, data):
self.ser.write((data + '\r\n').encode()) self.ser.write((data + '\r\n').encode())
@@ -126,9 +159,12 @@ class Pololu1376:
if __name__ == '__main__': 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 = Pololu1376(serial_port=ser)
drv.connect()
drv.go() drv.go()
time.sleep(1) time.sleep(1)
+25 -3
View File
@@ -10,12 +10,27 @@ class StirrerPololu1376(AStirrer):
return "Pololu1376" return "Pololu1376"
def __init__(self, dt, params): def __init__(self, dt, params):
AStirrer.__init__(self, dt) AStirrer.__init__(self, dt, simulated=False)
ser = serial.Serial(params['port'], params['speed']) # Deferred-open pyserial idiom - see Pololu1376's own constructor
ser.set_output_flow_control(False) # 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) 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 @contextmanager
def remote_open(self): def remote_open(self):
try: try:
@@ -30,6 +45,8 @@ class StirrerPololu1376(AStirrer):
return 0 return 0
def activate(self, enable): def activate(self, enable):
if not self.connected:
return
print("activate {}".format(enable)) print("activate {}".format(enable))
if enable: if enable:
self.drv.go() self.drv.go()
@@ -37,10 +54,14 @@ class StirrerPololu1376(AStirrer):
self.drv.stop() self.drv.stop()
def _on_set_speed(self, speed): def _on_set_speed(self, speed):
if not self.connected:
return
self.drv.motor_forward(speed) self.drv.motor_forward(speed)
print("Set speed to {} %".format(speed)) print("Set speed to {} %".format(speed))
def _on_process(self): def _on_process(self):
if not self.connected:
return
status = self.drv.get_variable(Varid.STATUS) status = self.drv.get_variable(Varid.STATUS)
limit_status = self.drv.get_variable(Varid.STATUS_LIMIT_STATUS) limit_status = self.drv.get_variable(Varid.STATUS_LIMIT_STATUS)
if status & 0x01 == 0x01: if status & 0x01 == 0x01:
@@ -51,6 +72,7 @@ class StirrerPololu1376(AStirrer):
if __name__ == '__main__': if __name__ == '__main__':
s = StirrerPololu1376(dt=1.0, params={'port': "/dev/ttyACM0", 'speed': 115200}) s = StirrerPololu1376(dt=1.0, params={'port': "/dev/ttyACM0", 'speed': 115200})
s.connect()
with s.remote_open(): with s.remote_open():
+4 -4
View File
@@ -1,11 +1,11 @@
import abc import abc
from utils.value import AttributeChange from components.connectable import Connectable
from contextlib import contextmanager from contextlib import contextmanager
class AHeater(AttributeChange): class AHeater(Connectable):
def __init__(self): def __init__(self, simulated=True):
AttributeChange.__init__(self) Connectable.__init__(self, simulated)
self.is_active = False self.is_active = False
@abc.abstractmethod @abc.abstractmethod
+4 -4
View File
@@ -1,11 +1,11 @@
import abc import abc
from utils.value import AttributeChange from components.connectable import Connectable
from contextlib import contextmanager from contextlib import contextmanager
class AStirrer(AttributeChange): class AStirrer(Connectable):
def __init__(self, dt): def __init__(self, dt, simulated=True):
AttributeChange.__init__(self) Connectable.__init__(self, simulated)
self.dt = dt self.dt = dt
self.speed = 0 self.speed = 0
self.cycleTime = 1 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
+2 -1
View File
@@ -42,4 +42,5 @@ class TempSensorSim(ATemperatureSensor):
innovation = self.stirrer_sigma * np.sqrt(2.0 * alpha) * np.random.normal() innovation = self.stirrer_sigma * np.sqrt(2.0 * alpha) * np.random.normal()
self._stirrer_state = (1.0 - alpha) * self._stirrer_state + innovation self._stirrer_state = (1.0 - alpha) * self._stirrer_state + innovation
return self.temp_set + self.offset + white + self._stirrer_state self.temp = self.temp_set + self.offset + white + self._stirrer_state
return self.temp
+2 -1
View File
@@ -47,7 +47,8 @@ class TempSensor_max31865(ATemperatureSensor):
def temperature(self): def temperature(self):
digits = self.read_digits() digits = self.read_digits()
return self.temp_offset + self.to_temperature(digits) self.temp = self.temp_offset + self.to_temperature(digits)
return self.temp
@staticmethod @staticmethod
def to_temperature(digits): def to_temperature(digits):
+3
View File
@@ -79,6 +79,9 @@ def main():
args = parser.parse_args() args = parser.parse_args()
hendi = HendiCtrl(args.serial_port, args.baudrate, debug=args.debug) hendi = HendiCtrl(args.serial_port, args.baudrate, debug=args.debug)
if not hendi.connect():
print("Could not connect to Hendi - check --serial-port/--baudrate and that the device is powered on.")
return
if args.reset: if args.reset:
reset(hendi) reset(hendi)
+8 -2
View File
@@ -139,9 +139,15 @@ if __name__ == '__main__':
forecast_estimator = SudForecastEstimator( forecast_estimator = SudForecastEstimator(
DT, theta_amb, config['TempCtrl']['pid_type'], config['TempCtrl'], DT, theta_amb, config['TempCtrl']['pid_type'], config['TempCtrl'],
heater.get_powers(), sim_warp_factor, config.get('Pot', {})) heater.get_powers(), sim_warp_factor, config.get('Pot', {}))
sud_task = SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"), forecast_estimator) sud_task = SudTask(sud, tc, stirrer, heater, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"), forecast_estimator)
taskmgr.add(sud_task) taskmgr.add(sud_task)
# A run in progress gets force-stopped (mirrors a manual Stop) if the
# heater or stirrer it depends on disconnects mid-brew - see SudTask.
# check_connections()'s own comment for why.
heater_task.set_on_connected_changed(lambda connected: sud_task.check_connections())
stirrer_task.set_on_connected_changed(lambda connected: sud_task.check_connections())
# How often the server/sud logs checkpoint themselves to disk (in # How often the server/sud logs checkpoint themselves to disk (in
# addition to always writing on their own end trigger) - so a hard # addition to always writing on their own end trigger) - so a hard
# crash/power loss only loses up to this much data. Config-only (no CLI # crash/power loss only loses up to this much data. Config-only (no CLI
@@ -182,7 +188,7 @@ if __name__ == '__main__':
sud_task.set_on_end(lambda: (heater_task.shutdown(), sud_log_task.stop_run())) sud_task.set_on_end(lambda: (heater_task.shutdown(), sud_log_task.stop_run()))
# Assign temp. sensor readings to tc # Assign temp. sensor readings to tc
sensor_task.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set) sensor.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set)
heater.set_on_changed("power_set", tc.set_model_power) heater.set_on_changed("power_set", tc.set_model_power)
+43 -1
View File
@@ -18,12 +18,29 @@ class HeaterTask(ATask):
self.closed_loop = True self.closed_loop = True
self._on_closed_loop_changed = None self._on_closed_loop_changed = None
self.pulse_counter = 0 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('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 self.power_set_changed = ChangedInteger(self.on_changed_power_set).set
def set_on_closed_loop_changed(self, callback): def set_on_closed_loop_changed(self, callback):
self._on_closed_loop_changed = 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): def shutdown(self):
"""Called when the brew ends (DONE/IDLE). Switches to open-loop at """Called when the brew ends (DONE/IDLE). Switches to open-loop at
power 0 and disables TC, mirroring what a Stop press should do.""" 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: if self._on_closed_loop_changed:
self._on_closed_loop_changed(self.closed_loop) self._on_closed_loop_changed(self.closed_loop)
await self.send({'ClosedLoop': 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]: elif 'Power' in pair[0]:
self.power_soll = pair[1] self.power_soll = pair[1]
@@ -82,6 +105,7 @@ class HeaterTask(ATask):
async def on_process(self): async def on_process(self):
await self.send({'Capabilities': {'Power': {'Min': 0, 'Max': self.device.get_power_max()}}}) 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}) await self.send({'ClosedLoop': self.closed_loop})
if self._on_closed_loop_changed: if self._on_closed_loop_changed:
self._on_closed_loop_changed(self.closed_loop) self._on_closed_loop_changed(self.closed_loop)
@@ -89,6 +113,20 @@ class HeaterTask(ATask):
pulse_period_s = 10 pulse_period_s = 10
pulse_period_count = pulse_period_s/self.interval 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(): with self.device.open():
while True: while True:
# Closed-loop: TC has full control. Open-loop: direct manual power only. # 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: elif self.pulse_counter < on_count:
self.device.set_power(power_high) 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) await asyncio.sleep(self.interval)
+40 -2
View File
@@ -11,9 +11,12 @@ class StirrerTask(ATask):
self.msg_handler = msg_handler self.msg_handler = msg_handler
msg_handler.set_recv_handler(self.recv) msg_handler.set_recv_handler(self.recv)
self.device = stirrer_device self.device = stirrer_device
self._on_connected_changed = None
stirrer_device.set_on_changed("speed", self.on_speed_changed) stirrer_device.set_on_changed("speed", self.on_speed_changed)
stirrer_device.set_on_changed("dutyCycle", self.on_dutycycle_changed) stirrer_device.set_on_changed("dutyCycle", self.on_dutycycle_changed)
stirrer_device.set_on_changed("cycleTime", self.on_cycletime_changed) stirrer_device.set_on_changed("cycleTime", self.on_cycletime_changed)
stirrer_device.set_on_changed("connected", self.on_connected_changed)
stirrer_device.set_on_changed("firmware_version", self.on_firmware_version_changed)
def on_speed_changed(self, value): def on_speed_changed(self, value):
asyncio.create_task(self.send({'Speed': value})) asyncio.create_task(self.send({'Speed': value}))
@@ -24,20 +27,55 @@ class StirrerTask(ATask):
def on_cycletime_changed(self, value): def on_cycletime_changed(self, value):
asyncio.create_task(self.send({'CycleTime': value})) asyncio.create_task(self.send({'CycleTime': value}))
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}))
async def recv(self, data): async def recv(self, data):
for pair in data.items(): for pair in data.items():
if 'Speed' in pair[0]: if pair[0] == 'Connect':
if self.device.connect():
self.device.activate(True)
elif pair[0] == 'Disconnect':
self.device.activate(False)
self.device.disconnect()
elif 'Speed' in pair[0]:
self.device.set_speed(pair[1]) self.device.set_speed(pair[1])
async def send(self, data): async def send(self, data):
await self.msg_handler.send(data) await self.msg_handler.send(data)
async def on_process(self): async def on_process(self):
await self.send({'Simulated': self.device.simulated})
self.device.set_speed(0.0) self.device.set_speed(0.0)
self.device.set_cycle_time(10.0) self.device.set_cycle_time(10.0)
self.device.set_duty_cycle(1.0) self.device.set_duty_cycle(1.0)
# Auto-connect on startup - see HeaterTask.on_process()'s own
# comment for why this replaces the old constructor-side connect.
self.device.connect()
# Explicit initial push - see HeaterTask.on_process()'s own comment
# for why a simulated device's connect() alone doesn't fire the
# observer.
await self.send({'Connected': self.device.connected})
await self.send({'FirmwareVersion': self.device.firmware_version})
with self.device.open(): with self.device.open():
while True: while True:
self.device.process() try:
self.device.process()
except Exception as e:
print(f"StirrerTask: comm error, marking disconnected: {e}")
self.device.disconnect()
await asyncio.sleep(self.interval) await asyncio.sleep(self.interval)
+32 -2
View File
@@ -3,7 +3,7 @@ import bisect
from tasks import ATask from tasks import ATask
from ws.message import MsgIo from ws.message import MsgIo
from utils.value import ChangedFloat from utils.value import ChangedFloat
from components import APid, AStirrer from components import APid, AStirrer, AHeater
from components.plant import APlant from components.plant import APlant
from components.sud import Sud, SudState from components.sud import Sud, SudState
@@ -37,12 +37,13 @@ def _downsample(t, theta, max_points=MAX_FORECAST_POINTS):
class SudTask(ATask): class SudTask(ATask):
def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, pot: APlant, dt, interval, msg_handler: MsgIo, def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, heater: AHeater, pot: APlant, dt, interval, msg_handler: MsgIo,
forecast_estimator=None): forecast_estimator=None):
ATask.__init__(self, interval) ATask.__init__(self, interval)
self.sud = sud self.sud = sud
self.tc = tc self.tc = tc
self.stirrer = stirrer self.stirrer = stirrer
self.heater = heater
self.pot = pot self.pot = pot
# Simulated seconds per tick, vs. interval's wall-clock seconds per # Simulated seconds per tick, vs. interval's wall-clock seconds per
# tick - same dt/interval split Pot/TempController/Stirrer already # tick - same dt/interval split Pot/TempController/Stirrer already
@@ -255,6 +256,24 @@ class SudTask(ATask):
def set_on_start(self, callback): def set_on_start(self, callback):
self._on_start = callback self._on_start = callback
def check_connections(self):
"""Called whenever the heater's or stirrer's connection state
changes (wired up in server/brewpi.py via HeaterTask/StirrerTask's
set_on_connected_changed()) - force-stops a run already in progress
if either has dropped, since continuing to run a schedule with a
dead actuator is misleading. Reuses Sud.stop() (already a no-op
outside RAMPING/HOLDING/WAIT_USER/PAUSED), the same path a manual
Stop press takes, so all the usual shutdown plumbing (heater
shutdown, sud log stop_run - see SudTask.set_on_end()'s wiring in
server/brewpi.py) fires exactly as it would for Stop."""
if self.sud.state in (SudState.IDLE, SudState.DONE):
return
if self.heater.connected and self.stirrer.connected:
return
self.sud.stop()
asyncio.create_task(self.send({'Error': 'Heater/Stirrer disconnected - brew stopped.'}))
asyncio.create_task(self.send({'Error': None}))
def on_state_changed(self, value): def on_state_changed(self, value):
asyncio.create_task(self.send({'State': str(value)})) asyncio.create_task(self.send({'State': str(value)}))
@@ -473,6 +492,17 @@ class SudTask(ATask):
async def recv(self, data): async def recv(self, data):
for pair in data.items(): for pair in data.items():
if 'Start' in pair[0]: if 'Start' in pair[0]:
# Refuse to (re)start a brew if either actuator it depends
# on isn't actually connected - continuing to "run" a
# schedule with a dead heater/stirrer is misleading. Mirrors
# the 'Load'-while-running refusal below: send the Error then
# immediately clear it, since the dispatcher has no concept of
# a one-shot event (see that branch's own comment).
missing = [name for name, device in (('heater', self.heater), ('stirrer', self.stirrer)) if not device.connected]
if missing:
await self.send({'Error': f"Cannot start - {' and '.join(missing)} not connected."})
await self.send({'Error': None})
continue
# A fresh start (not a resume from Pause, which keeps # A fresh start (not a resume from Pause, which keeps
# whatever forecast the run already established) re- # whatever forecast the run already established) re-
# anchors the forecast to the real temperature right now # anchors the forecast to the real temperature right now
+7 -3
View File
@@ -12,8 +12,12 @@ class TempSensorTask(ATask):
self.msg_handler = msg_handler self.msg_handler = msg_handler
msg_handler.set_recv_handler(self.recv) msg_handler.set_recv_handler(self.recv)
self.sensor = sensor_device self.sensor = sensor_device
self.temp = self.sensor.temperature() # Prime before registering - temperature() sets self.sensor.temp,
self.set_on_changed("temp", ChangedFloat(self.on_temp_changed, prec=1).set) # which would otherwise fire on_temp_changed() (and its
# asyncio.create_task()) before the event loop is running, since
# all tasks are built synchronously at module level in brewpi.py.
self.sensor.temperature()
self.sensor.set_on_changed("temp", ChangedFloat(self.on_temp_changed, prec=1).set)
def on_temp_changed(self, value): def on_temp_changed(self, value):
asyncio.create_task(self.send({'Temp': value})) asyncio.create_task(self.send({'Temp': value}))
@@ -26,5 +30,5 @@ class TempSensorTask(ATask):
async def on_process(self): async def on_process(self):
while True: while True:
self.temp = self.sensor.temperature() self.sensor.temperature()
await asyncio.sleep(self.interval) await asyncio.sleep(self.interval)
+67 -4
View File
@@ -41,6 +41,16 @@ let heaterMaxPower = 100;
// Only switchable when not running; auto-reset to true on play. // Only switchable when not running; auto-reset to true on play.
let closedLoop = true; let closedLoop = true;
// Hardware connection status (Heater/Stirrer channels) - simulated devices
// are always connected and never expose Connect/Disconnect (see
// updateDeviceStatus()).
let heaterConnected = false;
let heaterFirmware = null;
let heaterSimulated = null;
let stirrerConnected = false;
let stirrerFirmware = null;
let stirrerSimulated = null;
// Pot hardware config from server's Pot section - used for display before // Pot hardware config from server's Pot section - used for display before
// any sud is loaded (startup water level, status line). // any sud is loaded (startup water level, status line).
let potConfig = {}; let potConfig = {};
@@ -508,15 +518,17 @@ function updateControlsEnabled() {
const sudRunning = RUNNING_STATES.has(sudState); const sudRunning = RUNNING_STATES.has(sudState);
document.getElementById('temp-soll').disabled = !(closedLoop && !sudRunning); document.getElementById('temp-soll').disabled = !(closedLoop && !sudRunning);
document.getElementById('heatrate-soll').disabled = !(closedLoop && !sudRunning); document.getElementById('heatrate-soll').disabled = !(closedLoop && !sudRunning);
document.getElementById('heater-power').disabled = !(!closedLoop && !sudRunning); document.getElementById('heater-power').disabled = !(!closedLoop && !sudRunning && heaterConnected);
document.getElementById('stirrer-speed').disabled = sudRunning; document.getElementById('stirrer-speed').disabled = sudRunning || !stirrerConnected;
document.getElementById('closed-loop').disabled = sudRunning; document.getElementById('closed-loop').disabled = sudRunning;
} }
// Mirrors client/brewpi_gui.py's update_sud_actions(): Start also doubles // Mirrors client/brewpi_gui.py's update_sud_actions(): Start also doubles
// as Resume while paused; Stop can still abort a paused run. // as Resume while paused; Stop can still abort a paused run. A Sud can't be
// (re)started unless both actuators it depends on are connected - mirrors
// the server-side refusal in tasks/sud.py's SudTask.recv().
function updateSudActions() { function updateSudActions() {
const canRun = connected && !sudEmpty; const canRun = connected && !sudEmpty && heaterConnected && stirrerConnected;
const running = canRun && RUNNING_STATES.has(sudState); const running = canRun && RUNNING_STATES.has(sudState);
const paused = canRun && sudState === PAUSED_STATE; const paused = canRun && sudState === PAUSED_STATE;
document.getElementById('btn-sud-start').disabled = !(canRun && !running); document.getElementById('btn-sud-start').disabled = !(canRun && !running);
@@ -524,6 +536,23 @@ function updateSudActions() {
document.getElementById('btn-sud-stop').disabled = !(running || paused); document.getElementById('btn-sud-stop').disabled = !(running || paused);
} }
// Shared by onHeaterChanged/onStirrerChanged - updates the status badge,
// firmware label, and Connect/Disconnect buttons for one device. Simulated
// devices are always connected and have nothing to connect/disconnect, so
// their buttons are hidden entirely rather than just disabled.
function updateDeviceStatus(prefix, isConnected, firmware, simulated) {
const statusEl = document.getElementById(`${prefix}-status`);
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
statusEl.className = isConnected ? 'status-connected' : 'status-disconnected';
document.getElementById(`${prefix}-firmware`).textContent = firmware ? `F/W ${firmware}` : '';
const connectBtn = document.getElementById(`btn-${prefix}-connect`);
const disconnectBtn = document.getElementById(`btn-${prefix}-disconnect`);
connectBtn.classList.toggle('hidden', !!simulated);
disconnectBtn.classList.toggle('hidden', !!simulated);
connectBtn.disabled = isConnected;
disconnectBtn.disabled = !isConnected;
}
// Mirrors client/brewpi_gui.py's show_user_message(): the Sud is already // Mirrors client/brewpi_gui.py's show_user_message(): the Sud is already
// blocked in WAIT_USER server-side - dismissing this dialog (OK or Escape, // blocked in WAIT_USER server-side - dismissing this dialog (OK or Escape,
// both fire the dialog's 'close' event) is what unblocks it. // both fire the dialog's 'close' event) is what unblocks it.
@@ -610,6 +639,17 @@ function onHeaterChanged(msg) {
const slider = document.getElementById('heater-power'); const slider = document.getElementById('heater-power');
slider.min = power.Min; slider.min = power.Min;
slider.max = power.Max; slider.max = power.Max;
} else if (key === 'Connected') {
heaterConnected = msg.Connected;
updateDeviceStatus('heater', heaterConnected, heaterFirmware, heaterSimulated);
updateControlsEnabled();
updateSudActions();
} else if (key === 'FirmwareVersion') {
heaterFirmware = msg.FirmwareVersion;
updateDeviceStatus('heater', heaterConnected, heaterFirmware, heaterSimulated);
} else if (key === 'Simulated') {
heaterSimulated = msg.Simulated;
updateDeviceStatus('heater', heaterConnected, heaterFirmware, heaterSimulated);
} }
} }
} }
@@ -627,6 +667,17 @@ function onStirrerChanged(msg) {
const slider = document.getElementById('stirrer-speed'); const slider = document.getElementById('stirrer-speed');
slider.min = power.Min; slider.min = power.Min;
slider.max = power.Max; slider.max = power.Max;
} else if (key === 'Connected') {
stirrerConnected = msg.Connected;
updateDeviceStatus('stirrer', stirrerConnected, stirrerFirmware, stirrerSimulated);
updateControlsEnabled();
updateSudActions();
} else if (key === 'FirmwareVersion') {
stirrerFirmware = msg.FirmwareVersion;
updateDeviceStatus('stirrer', stirrerConnected, stirrerFirmware, stirrerSimulated);
} else if (key === 'Simulated') {
stirrerSimulated = msg.Simulated;
updateDeviceStatus('stirrer', stirrerConnected, stirrerFirmware, stirrerSimulated);
} }
} }
} }
@@ -888,6 +939,18 @@ document.getElementById('btn-connect').addEventListener('click', () => {
document.getElementById('closed-loop').addEventListener('change', (e) => { document.getElementById('closed-loop').addEventListener('change', (e) => {
sendMsg('Heater', {ClosedLoop: e.target.checked}); sendMsg('Heater', {ClosedLoop: e.target.checked});
}); });
document.getElementById('btn-heater-connect').addEventListener('click', () => {
sendMsg('Heater', {Connect: true});
});
document.getElementById('btn-heater-disconnect').addEventListener('click', () => {
sendMsg('Heater', {Disconnect: true});
});
document.getElementById('btn-stirrer-connect').addEventListener('click', () => {
sendMsg('Stirrer', {Connect: true});
});
document.getElementById('btn-stirrer-disconnect').addEventListener('click', () => {
sendMsg('Stirrer', {Disconnect: true});
});
document.getElementById('heater-power').addEventListener('input', (e) => { document.getElementById('heater-power').addEventListener('input', (e) => {
document.getElementById('heater-power-readout').textContent = e.target.value; document.getElementById('heater-power-readout').textContent = e.target.value;
sendMsg('Heater', {Power: Number(e.target.value)}); sendMsg('Heater', {Power: Number(e.target.value)});
+12
View File
@@ -51,6 +51,12 @@
</div> </div>
<div class="panel"> <div class="panel">
<h3>Heater</h3> <h3>Heater</h3>
<div class="device-status-row">
<span id="heater-status" class="status-disconnected">Disconnected</span>
<span id="heater-firmware" class="device-firmware"></span>
<button id="btn-heater-connect">Connect</button>
<button id="btn-heater-disconnect" disabled>Disconnect</button>
</div>
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" id="closed-loop" checked> Closed-loop <input type="checkbox" id="closed-loop" checked> Closed-loop
</label> </label>
@@ -63,6 +69,12 @@
</div> </div>
<div class="panel"> <div class="panel">
<h3>Stirrer</h3> <h3>Stirrer</h3>
<div class="device-status-row">
<span id="stirrer-status" class="status-disconnected">Disconnected</span>
<span id="stirrer-firmware" class="device-firmware"></span>
<button id="btn-stirrer-connect">Connect</button>
<button id="btn-stirrer-disconnect" disabled>Disconnect</button>
</div>
<label>Speed [%] <label>Speed [%]
<div class="slider-row"> <div class="slider-row">
<input type="range" id="stirrer-speed" min="0" max="100" step="1"> <input type="range" id="stirrer-speed" min="0" max="100" step="1">
+10
View File
@@ -333,6 +333,16 @@ header#connection-bar {
#pot-reset-row { display: flex; align-items: center; gap: 0.6em; margin: 0.4em 0; } #pot-reset-row { display: flex; align-items: center; gap: 0.6em; margin: 0.4em 0; }
.device-status-row {
display: flex;
align-items: center;
gap: 0.6em;
margin: 0.4em 0;
font-size: 0.9em;
}
.device-status-row button { padding: 0.15em 0.6em; font-size: 0.9em; }
.device-firmware { color: var(--text-muted); }
/* --- Hidden --- */ /* --- Hidden --- */
.hidden { display: none !important; } .hidden { display: none !important; }