Add Closed-loop/Open-loop heater control mode

Closed-loop (default, checkbox checked): TC has exclusive control over
the heater; temp-soll and heatrate-soll sliders are active; direct
heater power entry is disabled. TC now stays enabled at server startup
and whenever closed-loop mode is active — SudTask no longer disables it
on IDLE/DONE, so adjusting temp-soll manually with no sud running
immediately drives the heater via the TC.

Open-loop (checkbox unchecked, only selectable when paused/stopped):
TC is disconnected from the heater; only the heater-power slider is
active. Switching back to closed-loop resets power_soll to 0 and
re-enables the TC.

On play, the mode is automatically reset to closed-loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
This commit is contained in:
2026-06-28 10:29:30 +02:00
co-authored by Claude Sonnet 4.6
parent 956f32e6ea
commit f55d085ae4
6 changed files with 53 additions and 16 deletions
+29 -5
View File
@@ -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)});
+3
View File
@@ -43,6 +43,9 @@
</div>
<div class="panel">
<h3>Heater</h3>
<label class="checkbox-label">
<input type="checkbox" id="closed-loop" checked> Closed-loop
</label>
<label>Power [W]
<div class="slider-row">
<input type="range" id="heater-power" min="0" max="100" step="1">
+1
View File
@@ -289,6 +289,7 @@ header#connection-bar {
}
.slider-row input[type="range"] { flex: 1; accent-color: var(--accent); }
.panel input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; }
.panel label.checkbox-label { flex-direction: row; align-items: center; gap: 0.5em; cursor: pointer; }
.panel input[type="number"] { width: 6em; }
#pot-reset-row { display: flex; align-items: center; gap: 0.6em; margin: 0.4em 0; }