Add connect/disconnect status for real heater/stirrer hardware #3
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user