Add connect/disconnect status for real heater/stirrer hardware #3

Merged
jens merged 2 commits from observer_refactor into master 2026-07-03 19:26:18 +02:00
18 changed files with 381 additions and 45 deletions
+20 -2
View File
@@ -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()
+38 -10
View File
@@ -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()
+40 -4
View File
@@ -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)
+25 -3
View File
@@ -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():
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+22
View File
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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):
+3
View File
@@ -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
View File
@@ -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)
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)});
+12
View File
@@ -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">
+10
View File
@@ -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; }