Merge pull request 'Add connect/disconnect status for real heater/stirrer hardware' (#3) from observer_refactor into master
Reviewed-on: http://192.168.22.90:3001/jayfield/brewpi/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 close(self):
|
||||
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()
|
||||
|
||||
@@ -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.firmware_version = None
|
||||
return False
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
+8
-2
@@ -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
|
||||
@@ -182,7 +188,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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
try:
|
||||
self.device.process()
|
||||
except Exception as e:
|
||||
print(f"HeaterTask: comm error, marking disconnected: {e}")
|
||||
self.device.disconnect()
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
|
||||
|
||||
+39
-1
@@ -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:
|
||||
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
|
||||
|
||||
+7
-3
@@ -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)
|
||||
|
||||
+67
-4
@@ -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)});
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
</div>
|
||||
<div class="panel">
|
||||
<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">
|
||||
<input type="checkbox" id="closed-loop" checked> Closed-loop
|
||||
</label>
|
||||
@@ -63,6 +69,12 @@
|
||||
</div>
|
||||
<div class="panel">
|
||||
<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 [%]
|
||||
<div class="slider-row">
|
||||
<input type="range" id="stirrer-speed" min="0" max="100" step="1">
|
||||
|
||||
@@ -333,6 +333,16 @@ header#connection-bar {
|
||||
|
||||
#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 { display: none !important; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user