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:
+43
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
+40
-2
@@ -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)
|
||||
|
||||
+32
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user