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 @@