feat: add connect/disconnect status for real heater/stirrer hardware

Heater and Stirrer can be real serial hardware (hendi, Pololu1376), but
there was no connection concept at all - the constructors opened the
port and crashed the whole server if the device was missing, with no
way to see connection status or firmware version and no way to
reconnect without a restart.

Adds an observable Connectable mixin (components/connectable.py) shared
by AHeater/AStirrer; real devices defer opening the serial port to an
explicit connect(), auto-connect on server startup, and surface
Connected/FirmwareVersion/Simulated plus manual Connect/Disconnect over
the web GUI. Heating/stirring and Sud Start are all gated on connection
state, and a disconnect mid-brew force-stops the run via the same path
as a manual Stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
This commit is contained in:
2026-07-03 19:18:59 +02:00
co-authored by Claude Sonnet 5
parent 698c019581
commit f20e617d81
15 changed files with 369 additions and 39 deletions
+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; }