diff --git a/server/brewpi.py b/server/brewpi.py index f5b53c3..e5e2001 100755 --- a/server/brewpi.py +++ b/server/brewpi.py @@ -143,6 +143,8 @@ if __name__ == '__main__': # Assign data flow # Assign tc control value to heater tc.set_on_changed("y", heater_task.actor) + # HeaterTask owns TC enable: enabled in closed-loop mode, disabled in open-loop. + heater_task.set_on_closed_loop_changed(tc.set_enabled) # Assign temp. sensor readings to tc sensor_task.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set) diff --git a/tasks/heater.py b/tasks/heater.py index 75ed4a0..3511b27 100755 --- a/tasks/heater.py +++ b/tasks/heater.py @@ -15,10 +15,15 @@ class HeaterTask(ATask): msg_handler.set_recv_handler(self.recv) self.power_soll = 0 self.power_actor = 0 + self.closed_loop = True + self._on_closed_loop_changed = None self.pulse_counter = 0 device.set_on_changed('power_eff', ChangedInteger(self.on_changed_power).set) 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 actor(self, y): self.power_actor = max(0, self.device.get_power_max() * y) @@ -30,7 +35,14 @@ class HeaterTask(ATask): async def recv(self, data): for pair in data.items(): - if 'Power' in pair[0]: + if pair[0] == 'ClosedLoop': + self.closed_loop = pair[1] + if self.closed_loop: + self.power_soll = 0 + if self._on_closed_loop_changed: + self._on_closed_loop_changed(self.closed_loop) + await self.send({'ClosedLoop': self.closed_loop}) + elif 'Power' in pair[0]: self.power_soll = pair[1] async def send(self, data): @@ -52,14 +64,17 @@ class HeaterTask(ATask): async def on_process(self): await self.send({'Capabilities': {'Power': {'Min': 0, 'Max': self.device.get_power_max()}}}) + await self.send({'ClosedLoop': self.closed_loop}) + if self._on_closed_loop_changed: + self._on_closed_loop_changed(self.closed_loop) pulse_period_s = 10 pulse_period_count = pulse_period_s/self.interval with self.device.open(): while True: - # Combine power set from different sources - power_soll = max(self.power_soll, self.power_actor) + # Closed-loop: TC has full control. Open-loop: direct manual power only. + power_soll = self.power_actor if self.closed_loop else self.power_soll self.power_set_changed(power_soll) # Calculate duty cycle diff --git a/tasks/sud.py b/tasks/sud.py index 29ba7a2..1c9b22b 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -220,14 +220,6 @@ class SudTask(ATask): if value in (SudState.DONE, SudState.IDLE): self.stirrer.set_duty_cycle(1.0) self.stirrer.set_speed(0) - # A finished/stopped run no longer owns the controller - hand - # control back to manual mode (off by default there too). - self.tc.set_enabled(False) - else: - # Any other state (RAMPING/HOLDING/WAIT_USER/PAUSED) means a - # run is in progress and needs the controller actively driving - # the heater. - self.tc.set_enabled(True) def on_user_message_changed(self, value): asyncio.create_task(self.send({'UserMessage': value})) diff --git a/web/app.js b/web/app.js index 9003edd..5de60a5 100644 --- a/web/app.js +++ b/web/app.js @@ -37,6 +37,9 @@ let warpFactor = 1.0; let heaterPowerIst = 0; let heaterPowerSoll = 0; let heaterMaxPower = 100; +// Closed-loop: TC drives heater exclusively. Open-loop: direct power_soll only. +// Only switchable when not running; auto-reset to true on play. +let closedLoop = true; // Sud state - mirrors the sud_* fields on client/brewpi_gui.py's Window. let sudSchedule = []; @@ -468,6 +471,19 @@ function updateStatusLine() { el.textContent = `${text} ${elapsedText} ${energyText}`; } +// Enables/disables sliders and the closed-loop checkbox based on mode and sud state. +// TC sliders (temp/heatrate): only when closed-loop and not running. +// Heater power slider: only when open-loop and not running. +// Stirrer: whenever not running. Checkbox: whenever not running. +function updateControlsEnabled() { + const sudRunning = RUNNING_STATES.has(sudState); + document.getElementById('temp-soll').disabled = !closedLoop; + document.getElementById('heatrate-soll').disabled = !closedLoop; + document.getElementById('heater-power').disabled = !(!closedLoop && !sudRunning); + document.getElementById('stirrer-speed').disabled = sudRunning; + 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. function updateSudActions() { @@ -555,6 +571,10 @@ function onHeaterChanged(msg) { } else if (key === 'Power') { heaterPowerIst = msg.Power; document.getElementById('lcd-power-ist').textContent = msg.Power; + } else if (key === 'ClosedLoop') { + closedLoop = msg.ClosedLoop; + document.getElementById('closed-loop').checked = closedLoop; + updateControlsEnabled(); } else if (key === 'Capabilities') { const power = msg.Capabilities.Power; heaterMaxPower = power.Max || 100; @@ -649,16 +669,16 @@ function onSudChanged(msg) { const prevState = sudState; const isRunningNow = RUNNING_STATES.has(newState); if (isRunningNow && !wasRunning) { + closedLoop = true; + document.getElementById('closed-loop').checked = true; + sendMsg('Heater', {ClosedLoop: true}); elapsed = 0; energyByStep = {}; energyCurrent = 0; } wasRunning = isRunningNow; sudState = newState; - const sliderDisabled = RUNNING_STATES.has(sudState); - for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed']) { - document.getElementById(id).disabled = sliderDisabled; - } + updateControlsEnabled(); if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) { showUserMessage(sudUserMessage); } else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) { @@ -771,6 +791,7 @@ function connect() { initialHeatrateSync = true; initialTempSollSync = true; initialPotTempSync = true; + closedLoop = true; setConnected(true); for (const channel of CHANNELS) { ws.send(JSON.stringify({'+': channel})); @@ -786,7 +807,7 @@ function connect() { statusEl.className = 'status-disconnected'; }; ws.onclose = () => { - for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed']) { + for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed', 'closed-loop']) { document.getElementById(id).disabled = false; } setConnected(false); @@ -820,6 +841,9 @@ document.getElementById('btn-connect').addEventListener('click', () => { } }); +document.getElementById('closed-loop').addEventListener('change', (e) => { + sendMsg('Heater', {ClosedLoop: e.target.checked}); +}); 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 f51a187..efc44ea 100644 --- a/web/index.html +++ b/web/index.html @@ -43,6 +43,9 @@

Heater

+