From 698c0195819e30dab66dc663540850c6346369ec Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 2 Jul 2026 22:03:50 +0200 Subject: [PATCH 1/2] refactor: make ATemperatureSensor's temp observable directly on the sensor TempSensorSim/TempSensor_max31865's temperature() now stores its reading on self.temp, so ATemperatureSensor's inherited AttributeChange (previously never triggered by anything) actually fires. TempSensorTask no longer keeps its own shadow copy of the reading - it registers its websocket-push callback on self.sensor directly and just drives the read each tick; server/brewpi.py's TC-feeding registration moved from sensor_task to sensor for the same reason. Priming read happens before registering the callback (not after) since all tasks are built synchronously at module level, before the asyncio event loop starts - registering first would fire on_temp_changed's asyncio.create_task() with no running loop. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U --- components/sensor/tempSensorSim.py | 3 ++- components/sensor/tempSensor_max31865.py | 3 ++- server/brewpi.py | 2 +- tasks/tempsensor.py | 10 +++++++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/components/sensor/tempSensorSim.py b/components/sensor/tempSensorSim.py index 06ce32d..07c8fa4 100644 --- a/components/sensor/tempSensorSim.py +++ b/components/sensor/tempSensorSim.py @@ -42,4 +42,5 @@ class TempSensorSim(ATemperatureSensor): innovation = self.stirrer_sigma * np.sqrt(2.0 * alpha) * np.random.normal() 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 diff --git a/components/sensor/tempSensor_max31865.py b/components/sensor/tempSensor_max31865.py index ae64f10..195efa1 100755 --- a/components/sensor/tempSensor_max31865.py +++ b/components/sensor/tempSensor_max31865.py @@ -47,7 +47,8 @@ class TempSensor_max31865(ATemperatureSensor): def temperature(self): 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 def to_temperature(digits): diff --git a/server/brewpi.py b/server/brewpi.py index 334f013..013d8d5 100755 --- a/server/brewpi.py +++ b/server/brewpi.py @@ -182,7 +182,7 @@ if __name__ == '__main__': sud_task.set_on_end(lambda: (heater_task.shutdown(), sud_log_task.stop_run())) # 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) diff --git a/tasks/tempsensor.py b/tasks/tempsensor.py index a023fe4..62c0654 100644 --- a/tasks/tempsensor.py +++ b/tasks/tempsensor.py @@ -12,8 +12,12 @@ class TempSensorTask(ATask): self.msg_handler = msg_handler msg_handler.set_recv_handler(self.recv) self.sensor = sensor_device - self.temp = self.sensor.temperature() - self.set_on_changed("temp", ChangedFloat(self.on_temp_changed, prec=1).set) + # Prime before registering - temperature() sets self.sensor.temp, + # 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): asyncio.create_task(self.send({'Temp': value})) @@ -26,5 +30,5 @@ class TempSensorTask(ATask): async def on_process(self): while True: - self.temp = self.sensor.temperature() + self.sensor.temperature() await asyncio.sleep(self.interval) From f20e617d81cc7e41eec19d3a32164fd959eff2e7 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Fri, 3 Jul 2026 19:18:59 +0200 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL --- components/actor/heater_hendi.py | 22 ++++++++- components/actor/hendiCtrl.py | 48 ++++++++++++++---- components/actor/pololu1376.py | 48 +++++++++++++++--- components/actor/stirrerpololu1376.py | 28 +++++++++-- components/aheater.py | 8 +-- components/astirrer.py | 8 +-- components/connectable.py | 22 +++++++++ scripts/hendi_ctrl_app.py | 3 ++ server/brewpi.py | 8 ++- tasks/heater.py | 44 ++++++++++++++++- tasks/stirrer.py | 42 +++++++++++++++- tasks/sud.py | 34 ++++++++++++- web/app.js | 71 +++++++++++++++++++++++++-- web/index.html | 12 +++++ web/style.css | 10 ++++ 15 files changed, 369 insertions(+), 39 deletions(-) create mode 100644 components/connectable.py diff --git a/components/actor/heater_hendi.py b/components/actor/heater_hendi.py index f8bd472..aae2b9f 100755 --- a/components/actor/heater_hendi.py +++ b/components/actor/heater_hendi.py @@ -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() diff --git a/components/actor/hendiCtrl.py b/components/actor/hendiCtrl.py index 3973b29..bb4c8a0 100755 --- a/components/actor/hendiCtrl.py +++ b/components/actor/hendiCtrl.py @@ -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() diff --git a/components/actor/pololu1376.py b/components/actor/pololu1376.py index 9a296e3..a4264fc 100644 --- a/components/actor/pololu1376.py +++ b/components/actor/pololu1376.py @@ -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) diff --git a/components/actor/stirrerpololu1376.py b/components/actor/stirrerpololu1376.py index 8af5346..7f76902 100644 --- a/components/actor/stirrerpololu1376.py +++ b/components/actor/stirrerpololu1376.py @@ -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(): diff --git a/components/aheater.py b/components/aheater.py index 79e2451..abe62da 100644 --- a/components/aheater.py +++ b/components/aheater.py @@ -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 diff --git a/components/astirrer.py b/components/astirrer.py index 9d5dc14..890991c 100644 --- a/components/astirrer.py +++ b/components/astirrer.py @@ -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 diff --git a/components/connectable.py b/components/connectable.py new file mode 100644 index 0000000..bb68045 --- /dev/null +++ b/components/connectable.py @@ -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 diff --git a/scripts/hendi_ctrl_app.py b/scripts/hendi_ctrl_app.py index faaffe2..c151a1e 100644 --- a/scripts/hendi_ctrl_app.py +++ b/scripts/hendi_ctrl_app.py @@ -79,6 +79,9 @@ def main(): args = parser.parse_args() 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: reset(hendi) diff --git a/server/brewpi.py b/server/brewpi.py index 013d8d5..89e3be9 100755 --- a/server/brewpi.py +++ b/server/brewpi.py @@ -139,9 +139,15 @@ if __name__ == '__main__': forecast_estimator = SudForecastEstimator( DT, theta_amb, config['TempCtrl']['pid_type'], config['TempCtrl'], 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) + # 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 # 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 diff --git a/tasks/heater.py b/tasks/heater.py index 175bcf0..a32f642 100755 --- a/tasks/heater.py +++ b/tasks/heater.py @@ -18,12 +18,29 @@ class HeaterTask(ATask): self.closed_loop = True self._on_closed_loop_changed = None 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('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 def set_on_closed_loop_changed(self, 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): """Called when the brew ends (DONE/IDLE). Switches to open-loop at 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: self._on_closed_loop_changed(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]: self.power_soll = pair[1] @@ -82,6 +105,7 @@ class HeaterTask(ATask): async def on_process(self): 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}) if self._on_closed_loop_changed: self._on_closed_loop_changed(self.closed_loop) @@ -89,6 +113,20 @@ class HeaterTask(ATask): pulse_period_s = 10 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(): while True: # 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: 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) diff --git a/tasks/stirrer.py b/tasks/stirrer.py index 08758c0..5f35154 100644 --- a/tasks/stirrer.py +++ b/tasks/stirrer.py @@ -11,9 +11,12 @@ class StirrerTask(ATask): self.msg_handler = msg_handler msg_handler.set_recv_handler(self.recv) self.device = stirrer_device + self._on_connected_changed = None 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("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): asyncio.create_task(self.send({'Speed': value})) @@ -24,20 +27,55 @@ class StirrerTask(ATask): def on_cycletime_changed(self, 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): 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]) async def send(self, data): await self.msg_handler.send(data) async def on_process(self): + await self.send({'Simulated': self.device.simulated}) + self.device.set_speed(0.0) self.device.set_cycle_time(10.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(): 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) diff --git a/tasks/sud.py b/tasks/sud.py index a2c2df0..2347134 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -3,7 +3,7 @@ import bisect from tasks import ATask from ws.message import MsgIo from utils.value import ChangedFloat -from components import APid, AStirrer +from components import APid, AStirrer, AHeater from components.plant import APlant from components.sud import Sud, SudState @@ -37,12 +37,13 @@ def _downsample(t, theta, max_points=MAX_FORECAST_POINTS): 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): ATask.__init__(self, interval) self.sud = sud self.tc = tc self.stirrer = stirrer + self.heater = heater self.pot = pot # Simulated seconds per tick, vs. interval's wall-clock seconds per # tick - same dt/interval split Pot/TempController/Stirrer already @@ -255,6 +256,24 @@ class SudTask(ATask): def set_on_start(self, 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): asyncio.create_task(self.send({'State': str(value)})) @@ -473,6 +492,17 @@ class SudTask(ATask): async def recv(self, data): for pair in data.items(): 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 # whatever forecast the run already established) re- # anchors the forecast to the real temperature right now diff --git a/web/app.js b/web/app.js index 61ebfd8..e6f537e 100644 --- a/web/app.js +++ b/web/app.js @@ -41,6 +41,16 @@ let heaterMaxPower = 100; // Only switchable when not running; auto-reset to true on play. 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 // any sud is loaded (startup water level, status line). let potConfig = {}; @@ -508,15 +518,17 @@ function updateControlsEnabled() { const sudRunning = RUNNING_STATES.has(sudState); document.getElementById('temp-soll').disabled = !(closedLoop && !sudRunning); document.getElementById('heatrate-soll').disabled = !(closedLoop && !sudRunning); - document.getElementById('heater-power').disabled = !(!closedLoop && !sudRunning); - document.getElementById('stirrer-speed').disabled = sudRunning; + document.getElementById('heater-power').disabled = !(!closedLoop && !sudRunning && heaterConnected); + document.getElementById('stirrer-speed').disabled = sudRunning || !stirrerConnected; document.getElementById('closed-loop').disabled = sudRunning; } // 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() { - const canRun = connected && !sudEmpty; + const canRun = connected && !sudEmpty && heaterConnected && stirrerConnected; const running = canRun && RUNNING_STATES.has(sudState); const paused = canRun && sudState === PAUSED_STATE; document.getElementById('btn-sud-start').disabled = !(canRun && !running); @@ -524,6 +536,23 @@ function updateSudActions() { 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 // blocked in WAIT_USER server-side - dismissing this dialog (OK or Escape, // both fire the dialog's 'close' event) is what unblocks it. @@ -610,6 +639,17 @@ function onHeaterChanged(msg) { const slider = document.getElementById('heater-power'); slider.min = power.Min; 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'); slider.min = power.Min; 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) => { 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-readout').textContent = e.target.value; sendMsg('Heater', {Power: Number(e.target.value)}); diff --git a/web/index.html b/web/index.html index de34d4c..59c03dc 100644 --- a/web/index.html +++ b/web/index.html @@ -51,6 +51,12 @@

Heater

+
+ Disconnected + + + +
@@ -63,6 +69,12 @@

Stirrer

+
+ Disconnected + + + +