Files
jensandClaude Sonnet 5 3b34f52bb0 feat: replace modal user-confirm dialog with persistent flashing button
Both clients used a modal OK dialog for a Sud step's WAIT_USER
confirmation (e.g. "Bitte Malz einfuellen und bestaetigen"). Replaced
with a persistent "Confirm" control in the top toolbar/header instead:
flashes yellow with the step's message while pending, and modally
disables the rest of the GUI (main content plus Start/Pause/Stop/
New/Save/Load, and Connect) until it's clicked - forcing the pending
step to be dealt with first. Disabled and blank otherwise.

client/brewpi_gui.py: set_user_confirm_pending() drives a QTimer blink
(same pattern as the existing "Cool down" indicator) and disables
centralwidget + the relevant toolbar QActions individually, so the new
button (a toolbar sibling) stays enabled; Start/Pause/Stop are restored
via update_sud_actions() rather than a blanket re-enable.

web/app.js: setUserConfirmPending() toggles a 'confirm-pending' class
(CSS @keyframes flash, matching the file's existing animation-by-class
convention) and a 'modally-disabled' class on #layout; also wired into
ws.onclose so a dropped connection doesn't leave the page stuck modally
disabled on a confirm that'll never resolve.

Also fixes a message-ordering race in both clients (found testing the
web version on an iPad, connecting fresh while a step was already
WAIT_USER): a step's UserMessage is set once when the step starts, well
before State later flips to WAIT_USER, so an already-connected client
sees them at genuinely different times - but a client connecting fresh
gets both bundled into one replayed snapshot, where State's key can
sort before UserMessage's. Processing State first checked the pending
guard before that same message had set the text, silently failing it
and leaving the button looking merely disabled. Both onSudChanged()/
on_sud_changed() now force UserMessage (and, in the web client, Json)
to process before State/Step regardless of the bundled dict's key
order, same pattern already used for the pre-existing Json-before-Step
case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 11:57:23 +02:00

1116 lines
42 KiB
JavaScript

// BrewPi browser client (v1: Manual + Progress tabs only - see README.md's
// "Browser client" section). Talks the exact same WebSocket pub/sub
// protocol as client/brewpi_gui.py - this file mirrors that client's logic
// directly wherever the two overlap, so see brewpi_gui.py's own comments
// for the *why* behind anything that looks non-obvious here.
const CHANNELS = ['Pot', 'Sensor', 'Heater', 'Stirrer', 'TempCtrl', 'Sud', 'System'];
// str(SudState.X) values (as sent in the 'State' message) that count as
// "running" - mirrors client/brewpi_gui.py's SUD_RUNNING_STATES.
const RUNNING_STATES = new Set(['SudState.RAMPING', 'SudState.HOLDING', 'SudState.WAIT_USER']);
const PAUSED_STATE = 'SudState.PAUSED';
const WAIT_USER_STATE = 'SudState.WAIT_USER';
let ws = null;
let connected = false;
// Live Manual-tab readouts (TempCtrl/Heater/Stirrer channels).
let plotTempIst = null;
let plotTempSoll = null;
let stirrerSpeedIst = 0;
// Only the very first authoritative read after connecting is used to set
// these particular sliders - thereafter they're purely user-controlled.
// Without this, a slider mid-drag would keep getting snapped back by the
// server's own *actual* (not commanded) readback - e.g. the heater's
// discrete power steps/duty-cycling mean 'Power' rarely matches whatever
// was just commanded exactly. Mirrors brewpi_gui.py's *_initial_update
// flags (there, needed to avoid echoing a command right back as the same
// value via Qt's signal/slot machinery; here the DOM itself never echoes
// a plain .value assignment as an 'input'/'change' event, but the
// stale-overwrite risk is the same).
let initialHeatrateSync = true;
let initialTempSollSync = true;
let initialPotTempSync = true;
let ambientTemp = null;
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;
// Hardware connection status (Heater/Stirrer channels) - simulated devices
// are always connected and never expose Connect/Disconnect (see
// updateDeviceStatus()).
let heaterConnected = false;
let heaterSimulated = null;
let stirrerConnected = false;
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 = {};
// Sud state - mirrors the sud_* fields on client/brewpi_gui.py's Window.
let sudSchedule = [];
let sudName = '';
let sudPotMass = 0;
let sudGrainMass = 0;
let sudWaterMass = 0;
let sudVolumen = 0;
let sudEmpty = true;
let sudLastLoadedDoc = null;
let sudState = null;
let wasRunning = false;
let sudStepIndex = null;
let sudStepDescr = null;
let sudStepType = null;
let sudHoldRemaining = 0;
let sudUserMessage = null;
// Never reset back to "unknown" just because a run finishes - see
// client/brewpi_gui.py's sud_elapsed_last for why (sud_elapsed_seconds
// there gates a forecast-plot view this client doesn't have, so that
// distinction doesn't apply here - this is simply always "the last real
// Elapsed value").
let elapsed = null;
let stepActualFrozen = {};
let forecastStepStarts = {};
let forecastFinished = false;
let forecastFinalT = null;
let energyByStep = {};
let energyCurrent = 0;
// Set right before sending {Sud: {Save: true}} - the next 'Json' push is
// then this request's reply (not a schedule update to render), so it
// triggers a file download instead - mirrors client/brewpi_gui.py's
// sud_save_path flag (on_action_sud_save()/on_sud_changed()'s 'Json'
// branch).
let sudSavePending = false;
let stepPlates = [];
let blinkOn = true;
function sendMsg(channel, payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({[channel]: payload}));
}
}
function formatDuration(seconds) {
seconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
// --- Sud schedule resolution - ports components/sud.py's _merge_defaults()/
// _build_step() verbatim, since the raw doc from 'Json' is unresolved. ---
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function mergeDefaults(defaults, override) {
const merged = Object.assign({}, defaults);
for (const [key, value] of Object.entries(override)) {
if (isPlainObject(merged[key]) && isPlainObject(value)) {
merged[key] = mergeDefaults(merged[key], value);
} else {
merged[key] = value;
}
}
return merged;
}
function buildStep(number, defaults, rawStep) {
const step = {number: number};
for (const key of ['descr', 'user_message', 'user_wait_for_continue']) {
step[key] = (key in rawStep) ? rawStep[key] : defaults[key];
}
const rawPot = rawStep.pot || {};
const defaultPot = defaults.pot || {};
for (const key of ['grain_mass', 'water_mass']) {
if (key in rawPot) step[key] = rawPot[key];
else if (key in defaultPot) step[key] = defaultPot[key];
}
// Deliberately not defaulted from default.step - a step omitting it
// has no new target of its own and keeps whatever the previous step
// left running (see components/sud.py's _build_step()).
step.temperature = ('temperature' in rawStep) ? rawStep.temperature : null;
step.ramp = mergeDefaults(defaults.ramp || {}, rawStep.ramp || {});
if ('hold' in rawStep) {
step.hold = mergeDefaults(defaults.hold || {}, rawStep.hold || {});
}
return step;
}
function parseSudDoc(doc) {
const pot = doc.pot || {};
const stepDefaults = (doc.default && doc.default.step) || {};
const schedule = (doc.steps || []).map((raw, i) => buildStep(i + 1, stepDefaults, raw));
return {
name: doc.Name || '',
schedule: schedule,
// Falls back to config.json's Pot.mass (via potConfig, set from
// the System message) when the loaded doc doesn't override it -
// mirrors components/sud.py's Sud._parse_data().
potMass: pot.mass || potConfig.mass || 0,
grainMass: pot.grain_mass || 0,
waterMass: pot.water_mass || 0,
volumen: pot.volumen || 0,
};
}
// --- Progress tab: one step-plate per schedule step - mirrors
// client/brewpi_gui.py's StepPlate/_rebuild_step_plates()/
// _update_step_plates(). ---
function createStepPlate(index, step, resolvedTemp) {
const el = document.createElement('div');
el.className = 'step-plate';
const led = document.createElement('div');
led.className = 'led';
const title = document.createElement('div');
title.className = 'title';
title.textContent = `Step ${index + 1}: ${step.descr || ''}`;
const remaining = document.createElement('div');
remaining.className = 'remaining';
remaining.textContent = '-:--:--';
const target = document.createElement('div');
target.className = 'field span2';
const actual = document.createElement('div');
actual.className = 'field';
const masses = document.createElement('div');
masses.className = 'field span2';
const energy = document.createElement('div');
energy.className = 'field';
const rate = document.createElement('div');
rate.className = 'field span2';
const stirrer = document.createElement('div');
stirrer.className = 'field';
el.append(led, title, remaining, target, actual, masses, energy, rate, stirrer);
const grain = step.grain_mass || 0;
const water = step.water_mass || 0;
masses.textContent = `Grain ${grain.toFixed(2)} kg / Water ${water.toFixed(2)} kg`;
rate.textContent = `Rate ${((step.ramp && step.ramp.rate) || 0).toFixed(2)} °/min`;
target.textContent = `Target ${resolvedTemp !== null ? resolvedTemp.toFixed(1) + ' °C' : '—'}`;
actual.textContent = (step.hold && step.hold.duration != null)
? `Hold ${formatDuration(step.hold.duration * 60)}` : 'Hold —';
// The hold phase's speed, not the ramp phase's, as the "configured"
// fallback shown for an inactive step - see StepPlate.set_step()'s
// own comment.
const holdStirrer = (step.hold && step.hold.stirrer) || {};
const rampStirrer = (step.ramp && step.ramp.stirrer) || {};
const configuredSpeed = ('speed' in holdStirrer) ? holdStirrer.speed : (rampStirrer.speed || 0);
return {el, led, remaining, target, actual, masses, energy, rate, stirrer, ledMode: 'off', configuredSpeed};
}
function rebuildStepPlates() {
const container = document.getElementById('step-plates');
container.innerHTML = '';
stepPlates = [];
let resolvedTemp = null;
sudSchedule.forEach((step, index) => {
if (step.temperature !== null && step.temperature !== undefined) {
resolvedTemp = step.temperature;
}
const plate = createStepPlate(index, step, resolvedTemp);
stepPlates.push(plate);
container.appendChild(plate.el);
});
updateStepPlates();
}
function applyLedColor(plate) {
if (plate.ledMode === 'done') {
plate.led.style.background = 'var(--led-green)';
} else if (plate.ledMode === 'ramp') {
plate.led.style.background = blinkOn ? 'var(--led-orange)' : 'var(--led-orange-dim)';
} else if (plate.ledMode === 'hold') {
plate.led.style.background = blinkOn ? 'var(--led-green)' : 'var(--led-green-dim)';
} else {
plate.led.style.background = 'var(--led-off)';
}
}
function setLed(plate, mode) {
plate.ledMode = mode;
applyLedColor(plate);
}
function updateStepPlates() {
if (stepPlates.length === 0) {
return;
}
const currentElapsed = (elapsed !== null) ? elapsed : 0;
const n = stepPlates.length;
stepPlates.forEach((plate, i) => {
const startT = forecastStepStarts[i];
let endT = forecastStepStarts[i + 1];
if (endT === undefined && i === n - 1 && forecastFinished) {
endT = forecastFinalT;
}
if (startT !== undefined && endT !== undefined) {
const stepTotal = Math.max(0, endT - startT);
const holdDurS = (sudSchedule[i] && sudSchedule[i].hold)
? (sudSchedule[i].hold.duration || 0) * 60 : 0;
const rampTotal = Math.max(0, stepTotal - holdDurS);
const rampVal = formatDuration(
Math.max(0, Math.min(rampTotal, endT - currentElapsed - holdDurS)));
// Active step in ramp phase gets the "ramp" prefix; hold branch
// overwrites remaining below with hold time instead.
plate.remaining.textContent =
(sudStepIndex === i && sudStepType === 'ramp') ? 'Ramp ' + rampVal : rampVal;
} else {
plate.remaining.textContent = '-:--:--';
}
if (sudStepIndex === null || i > sudStepIndex) {
setLed(plate, 'off');
} else if (i < sudStepIndex) {
setLed(plate, 'done');
} else {
// WAIT_USER/PAUSED don't change sudStepType - it stays
// whatever phase was actually last active, which is exactly
// the color this step's LED should keep flashing.
setLed(plate, (sudStepType === 'ramp' || sudStepType === 'hold') ? sudStepType : 'hold');
}
if (sudStepIndex !== null && i === sudStepIndex) {
plate.stirrer.textContent = `Stirrer ${stirrerSpeedIst.toFixed(0)} rpm`;
plate.energy.textContent = `Energy ${energyCurrent.toFixed(0)} Wh`;
// Active step: Ramp prefix when ramping; Hold time when holding
// (reuses the same digit area). PAUSED/WAIT_USER keep the last phase.
if (sudStepType === 'hold') {
plate.remaining.textContent = 'Hold ' + formatDuration(Math.max(0, sudHoldRemaining));
}
} else if (i in stepActualFrozen) {
plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`;
plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —';
} else {
plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`;
plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —';
}
});
updateHeaderCountdown();
}
function updateHeaderCountdown() {
const currentElapsed = (elapsed !== null) ? elapsed : 0;
const n = sudSchedule.length;
let rampText = '-:--:--';
let totalText = '-:--:--';
let visible = false;
if (n > 0 && sudStepIndex !== null) {
visible = true;
const startT = forecastStepStarts[sudStepIndex];
let endT = forecastStepStarts[sudStepIndex + 1];
if (endT === undefined && sudStepIndex === n - 1 && forecastFinished) {
endT = forecastFinalT;
}
if (startT !== undefined && endT !== undefined) {
const stepTotal = Math.max(0, endT - startT);
const step = sudSchedule[sudStepIndex];
const holdDurS = (step && step.hold) ? (step.hold.duration || 0) * 60 : 0;
const rampTotal = Math.max(0, stepTotal - holdDurS);
rampText = formatDuration(Math.max(0, Math.min(rampTotal, endT - currentElapsed - holdDurS)));
}
if (forecastFinalT !== null && elapsed !== null) {
totalText = formatDuration(Math.max(0, forecastFinalT - currentElapsed));
}
}
const holdText = (sudStepType === 'hold' && sudHoldRemaining > 0)
? formatDuration(Math.max(0, sudHoldRemaining)) : '-:--:--';
document.getElementById('hdr-step-remaining').textContent = rampText;
document.getElementById('hdr-total-remaining').textContent = totalText;
document.getElementById('hdr-hold-remaining').textContent = holdText;
document.getElementById('header-countdown').classList.toggle('hidden', !visible);
}
// --- Stirrer visualization ---
function updateStirrerVisualization() {
const g = document.getElementById('stirrer-blades');
const particles = document.getElementById('grain-particles');
if (!g) return;
if (stirrerSpeedIst > 0) {
const duration = Math.min(10, 50 / stirrerSpeedIst).toFixed(2);
g.style.setProperty('--stir-duration', duration + 's');
g.classList.add('spinning');
if (particles) particles.classList.add('stirring');
} else {
g.classList.remove('spinning');
if (particles) particles.classList.remove('stirring');
}
const rpmLabel = document.getElementById('svg-stirrer-rpm');
if (rpmLabel) rpmLabel.textContent = stirrerSpeedIst > 0 ? `${Math.round(stirrerSpeedIst)} rpm` : '';
}
// --- Fire visualization ---
function updateFireVisualization() {
const g = document.getElementById('fire-group');
if (!g) return;
if (heaterPowerSoll <= 0) {
g.style.opacity = '0';
g.removeAttribute('transform');
} else {
const intensity = Math.min(1, heaterPowerSoll / heaterMaxPower);
const minScale = 1200 / heaterMaxPower;
const scale = Math.max(minScale, intensity);
g.style.opacity = '1';
g.setAttribute('transform', `translate(0,228) scale(1,${scale.toFixed(3)}) translate(0,-248)`);
}
const powerLabel = document.getElementById('svg-power-soll');
if (powerLabel) powerLabel.textContent = heaterPowerSoll > 0 ? `${Math.round(heaterPowerSoll)} W` : '';
}
// --- Pot visualization ---
function wavePathD(x1, x2, y, amplitude, wavelength) {
let d = `M ${x1} ${y.toFixed(1)}`;
for (let x = x1 + 3; x <= x2; x += 3) {
const yw = y + amplitude * Math.sin(2 * Math.PI * (x - x1) / wavelength);
d += ` L ${x} ${yw.toFixed(1)}`;
}
return d;
}
function currentStepWaterMass() {
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
return sudSchedule[sudStepIndex].water_mass || 0;
}
return sudWaterMass;
}
function currentStepGrainMass() {
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
const step = sudSchedule[sudStepIndex];
if ('grain_mass' in step) return step.grain_mass;
}
return sudGrainMass;
}
function updatePotVisualization() {
// Thermometer mercury column (0°C = y=174, 100°C = y=28, tube length = 146)
const tubeBottom = 174;
const tubeLen = 146;
const temp = plotTempIst !== null ? Math.max(0, Math.min(100, plotTempIst)) : 0;
const mercuryH = (temp / 100) * tubeLen;
document.getElementById('thermo-mercury').setAttribute('y', (tubeBottom - mercuryH).toFixed(1));
document.getElementById('thermo-mercury').setAttribute('height', mercuryH.toFixed(1));
document.getElementById('thermo-temp-label').textContent =
plotTempIst !== null ? plotTempIst.toFixed(1) + ' °C' : '--';
// Soll temperature marker
const sollTick = document.getElementById('thermo-soll-tick');
const sollLabel = document.getElementById('thermo-soll-label');
if (plotTempSoll !== null) {
const sollY = (tubeBottom - Math.max(0, Math.min(100, plotTempSoll)) / 100 * tubeLen).toFixed(1);
sollTick.setAttribute('y1', sollY);
sollTick.setAttribute('y2', sollY);
sollLabel.setAttribute('y', sollY);
sollLabel.textContent = plotTempSoll.toFixed(0) + '°';
sollTick.style.display = '';
sollLabel.style.display = '';
} else {
sollTick.style.display = 'none';
sollLabel.style.display = 'none';
}
// Water fill
const waterFillEl = document.getElementById('water-fill');
const waterWaveEl = document.getElementById('water-wave');
const potInnerTop = 30;
const potInnerBottom = 172;
const potInnerH = potInnerBottom - potInnerTop; // 142
const particlesEl = document.getElementById('grain-particles');
const waterClipRect = document.getElementById('water-clip-rect');
const levelTickEl = document.getElementById('level-tick');
const levelLabelEl = document.getElementById('level-label');
const volumen = sudEmpty ? (potConfig.volumen || 0) : sudVolumen;
const grainMass = sudEmpty ? 0 : currentStepGrainMass();
const waterMass = sudEmpty ? (potConfig.water_mass || 0) : currentStepWaterMass();
if (volumen <= 0) {
waterFillEl.setAttribute('height', '0');
waterWaveEl.setAttribute('d', '');
if (waterClipRect) waterClipRect.setAttribute('height', '0');
if (particlesEl) particlesEl.style.opacity = '0';
if (levelTickEl) levelTickEl.style.display = 'none';
if (levelLabelEl) levelLabelEl.style.display = 'none';
return;
}
const utilizedVolume = waterMass + grainMass * 0.7;
const fillFraction = Math.min(1, Math.max(0, utilizedVolume / volumen));
const waterH = fillFraction * potInnerH;
const waterTop = potInnerBottom - waterH;
waterFillEl.setAttribute('y', waterTop.toFixed(1));
waterFillEl.setAttribute('height', waterH.toFixed(1));
waterWaveEl.setAttribute('d', wavePathD(63, 177, waterTop, 3, 28));
if (waterClipRect) {
waterClipRect.setAttribute('y', waterTop.toFixed(1));
waterClipRect.setAttribute('height', waterH.toFixed(1));
}
if (particlesEl) {
particlesEl.style.opacity = grainMass > 0 ? '1' : '0';
}
if (levelTickEl && levelLabelEl) {
const ty = waterTop.toFixed(1);
levelTickEl.setAttribute('y1', ty);
levelTickEl.setAttribute('y2', ty);
levelLabelEl.setAttribute('y', ty);
levelLabelEl.textContent = utilizedVolume.toFixed(1) + ' L';
levelTickEl.style.display = '';
levelLabelEl.style.display = '';
}
}
// --- Status line - mirrors update_status_step_label(). ---
function updateStatusLine() {
const el = document.getElementById('sud-status-line');
let text = '';
// Before any schedule is loaded, fall back to config.json's Pot
// section (via potConfig) so the status line already shows pot mass/
// water/volume/energy as soon as the System message arrives - same
// sudEmpty ? potConfig... : sud... pattern as updatePotVisualization()
// above. Step description/remaining-time only exist once a schedule
// is actually loaded, so they stay inside the sudEmpty===false branch.
let grain = 0, water = potConfig.water_mass || 0;
let pot = potConfig.mass || 0;
let volumen = potConfig.volumen || 0;
if (!sudEmpty) {
grain = sudGrainMass;
water = sudWaterMass;
pot = sudPotMass;
volumen = sudVolumen;
if (sudStepDescr) {
// +1: sudStepIndex is the 0-based index Sud.index uses, but the
// Progress tab's step-plates (createStepPlate()) label 1-based -
// match it here, same fix as client/brewpi_gui.py's
// update_status_step_label().
text = `Step ${sudStepIndex + 1}: ${sudStepDescr}`;
if (sudStepType === 'hold') {
const remaining = Math.max(sudHoldRemaining, 0);
text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`;
}
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
const step = sudSchedule[sudStepIndex];
grain = step.grain_mass || 0;
water = step.water_mass || 0;
}
}
}
const massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg, Volume ${volumen.toFixed(1)} L`;
text = text ? `${text} ${massText}` : massText;
const elapsedText = `Elapsed ${elapsed !== null ? formatDuration(elapsed) : '-:--:--'}`;
const totalEnergyKwh = (Object.values(energyByStep).reduce((a, b) => a + b, 0) + energyCurrent) / 1000.0;
const energyText = `Energy ${totalEnergyKwh.toFixed(2)} kWh`;
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 && !sudRunning);
document.getElementById('heatrate-soll').disabled = !(closedLoop && !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. 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 && heaterConnected && stirrerConnected;
const running = canRun && RUNNING_STATES.has(sudState);
const paused = canRun && sudState === PAUSED_STATE;
document.getElementById('btn-sud-start').disabled = !(canRun && !running);
document.getElementById('btn-sud-pause').disabled = !running;
document.getElementById('btn-sud-stop').disabled = !(running || paused);
}
// Shared by onHeaterChanged/onStirrerChanged - updates the status badge
// 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.
//
// isConnected is a tri-state: true/false once the server has actually
// reported it, or null for "unknown" - shown as a blank '---' badge with
// both buttons disabled, used while the browser itself has no connection
// to the server at all (see setConnected()) since a stale Connected/
// Disconnected from before that disconnect would otherwise misleadingly
// look current.
function updateDeviceStatus(prefix, isConnected, simulated) {
const statusEl = document.getElementById(`${prefix}-status`);
if (isConnected === null) {
statusEl.textContent = '---';
statusEl.className = 'panel-status status-disconnected';
} else {
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
statusEl.className = `panel-status ${isConnected ? 'status-connected' : 'status-disconnected'}`;
}
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 !== false;
disconnectBtn.disabled = isConnected !== true;
}
// Reflects closedLoop (the Controller panel's Enable toggle) in the
// Controller heading's status badge - Enabled/green when the TC is
// driving the heater, Disabled/gray in open-loop.
function updateClosedLoopStatus() {
const statusEl = document.getElementById('closed-loop-status');
statusEl.textContent = closedLoop ? 'Enabled' : 'Disabled';
statusEl.className = `panel-status ${closedLoop ? 'status-connected' : 'status-disconnected'}`;
}
// Mirrors client/brewpi_gui.py's set_user_confirm_pending(): a persistent
// header control rather than a modal dialog. While pending, the Confirm
// button flashes yellow with the step's message and #layout (everything
// except the header) is modally disabled via the 'modally-disabled' class
// (opacity + pointer-events:none - see style.css) so the pending step has
// to be dealt with before anything else. The Sud is already blocked in
// WAIT_USER server-side; clicking Confirm is what unblocks it.
let userConfirmPending = false;
const CONFIRM_DISABLE_IDS = ['btn-connect', 'btn-sud-start', 'btn-sud-pause', 'btn-sud-stop',
'btn-sud-new', 'btn-sud-load', 'btn-sud-save'];
function setUserConfirmPending(pending, message = '') {
if (pending === userConfirmPending) return;
userConfirmPending = pending;
const btn = document.getElementById('btn-user-confirm');
document.getElementById('user-confirm-text').textContent = pending ? message : '';
btn.disabled = !pending;
btn.classList.toggle('confirm-pending', pending);
document.getElementById('layout').classList.toggle('modally-disabled', pending);
for (const id of CONFIRM_DISABLE_IDS) {
document.getElementById(id).disabled = pending;
}
if (!pending) {
// Start/Pause/Stop depend on sudState/sudEmpty/connected, not just
// "was disabled for confirm" - let the usual logic pick the right
// one back up rather than assuming all should simply re-enable.
updateSudActions();
}
}
function downloadJson(doc) {
const blob = new Blob([JSON.stringify(doc, null, '\t')], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${doc.Name || 'sud'}.json`;
a.click();
URL.revokeObjectURL(url);
}
function updateEnvStatusLine() {
const el = document.getElementById('env-status-line');
const ambient = ambientTemp !== null ? `${ambientTemp.toFixed(1)}°C` : '-';
el.textContent = `Ambient: ${ambient} Warp: ${warpFactor.toFixed(1)}x`;
}
function updateSudForecast(doc) {
const parsed = parseSudDoc(doc);
// A genuinely different schedule resets run-progress state; a
// standalone re-fetch of the *same* running one (e.g. the Save sent
// on every connect) must not - see client/brewpi_gui.py's
// update_sud_forecast() for the bug this avoids.
const isNewSchedule = JSON.stringify(doc) !== JSON.stringify(sudLastLoadedDoc);
sudLastLoadedDoc = doc;
sudName = parsed.name;
sudSchedule = parsed.schedule;
sudPotMass = parsed.potMass;
sudGrainMass = parsed.grainMass;
sudWaterMass = parsed.waterMass;
sudVolumen = parsed.volumen;
sudEmpty = sudSchedule.length === 0;
if (isNewSchedule) {
sudStepDescr = null;
// sudStepIndex is NOT reset here: Step messages are the authoritative
// source. If a Step already arrived before this Json (e.g. from the
// global-state replay), resetting would leave the schedule built but
// the index null, causing grain/level to vanish until the next step
// change. The Step handler always overwrites it anyway.
sudStepType = null;
sudHoldRemaining = 0;
forecastStepStarts = {};
forecastFinished = false;
forecastFinalT = null;
stepActualFrozen = {};
energyByStep = {};
energyCurrent = 0;
elapsed = null;
}
rebuildStepPlates();
updateStatusLine();
updateSudActions();
updatePotVisualization();
document.title = sudName ? `BrewPi - ${sudName}` : 'BrewPi';
}
// --- Channel handlers - mirror client/brewpi_gui.py's on_*_changed(). ---
function onHeaterChanged(msg) {
for (const key of Object.keys(msg)) {
if (key === 'PowerSet') {
heaterPowerSoll = msg.PowerSet;
document.getElementById('lcd-power-soll').textContent = msg.PowerSet;
document.getElementById('heater-power').value = msg.PowerSet;
document.getElementById('heater-power-readout').textContent = msg.PowerSet;
updateFireVisualization();
} 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;
updateClosedLoopStatus();
updateControlsEnabled();
} else if (key === 'Capabilities') {
const power = msg.Capabilities.Power;
heaterMaxPower = power.Max || 100;
const slider = document.getElementById('heater-power');
slider.min = power.Min;
slider.max = power.Max;
} else if (key === 'Connected') {
heaterConnected = msg.Connected;
updateDeviceStatus('heater', heaterConnected, heaterSimulated);
updateControlsEnabled();
updateSudActions();
} else if (key === 'Simulated') {
heaterSimulated = msg.Simulated;
updateDeviceStatus('heater', heaterConnected, heaterSimulated);
}
}
}
function onStirrerChanged(msg) {
for (const key of Object.keys(msg)) {
if (key === 'Speed') {
stirrerSpeedIst = msg.Speed;
document.getElementById('stirrer-speed').value = msg.Speed;
document.getElementById('stirrer-speed-readout').textContent = msg.Speed;
updateStirrerVisualization();
updateStepPlates();
} else if (key === 'Capabilities') {
const power = msg.Capabilities.Power;
const slider = document.getElementById('stirrer-speed');
slider.min = power.Min;
slider.max = power.Max;
} else if (key === 'Connected') {
stirrerConnected = msg.Connected;
updateDeviceStatus('stirrer', stirrerConnected, stirrerSimulated);
updateControlsEnabled();
updateSudActions();
} else if (key === 'Simulated') {
stirrerSimulated = msg.Simulated;
updateDeviceStatus('stirrer', stirrerConnected, stirrerSimulated);
}
}
}
function onTempCtrlChanged(msg) {
for (const key of Object.keys(msg)) {
if (key === 'Soll') {
const soll = msg.Soll;
if ('Temp' in soll) {
plotTempSoll = soll.Temp;
document.getElementById('lcd-temp-soll').textContent = soll.Temp.toFixed(1);
const sudRunning = RUNNING_STATES.has(sudState);
if (initialTempSollSync || sudRunning) {
document.getElementById('temp-soll').value = Math.round(soll.Temp);
if (!sudRunning) initialTempSollSync = false;
}
document.getElementById('temp-soll-readout').textContent = document.getElementById('temp-soll').value;
updatePotVisualization();
}
if ('Rate' in soll) {
const rate = soll.Rate;
if ('Current' in rate) {
document.getElementById('lcd-rate-soll').textContent = rate.Current.toFixed(1);
}
const sudRunningHr = RUNNING_STATES.has(sudState);
if ('Set' in rate && (initialHeatrateSync || sudRunningHr)) {
document.getElementById('heatrate-soll').value = rate.Set;
if (!sudRunningHr) initialHeatrateSync = false;
}
}
}
if (key === 'Ist') {
const ist = msg.Ist;
if ('Temp' in ist) {
plotTempIst = ist.Temp;
document.getElementById('lcd-temp-ist').textContent = ist.Temp.toFixed(1);
updateStepPlates();
updatePotVisualization();
}
if ('Rate' in ist) {
document.getElementById('lcd-rate-ist').textContent = ist.Rate.toFixed(1);
}
}
}
}
function onSudChanged(msg) {
// Json must be processed before Step: both can arrive in the same
// global-state replay message, but Step was inserted into global_state
// first (at brew start), so it precedes Json in server key order.
// Processing Step first would set sudStepIndex before the schedule
// exists, then Json would reset it to null (isNewSchedule=true on
// first/reloaded connect) with no subsequent Step to restore it.
//
// UserMessage must likewise be processed before State: a step's
// user_message is set once, when the step starts (components/sud.py's
// _advance()), well before state later flips to WAIT_USER
// (_finish_step()) - so UserMessage's key was inserted into
// global_state earlier in the server's lifetime and would otherwise
// precede State here too. A client connecting fresh mid-confirm gets
// both bundled into this same replay message; processing State first
// would check sudUserMessage before this very message has set it,
// silently failing setUserConfirmPending()'s guard and leaving the
// Confirm button looking merely disabled instead of pending - the
// same race the old modal dialog had, just never noticed until now.
const keys = ['Json', 'UserMessage', ...Object.keys(msg).filter(k => k !== 'Json' && k !== 'UserMessage')];
for (const key of keys) {
if (!(key in msg)) continue;
if (key === 'Json') {
// A standalone reply to a just-sent {Save: true} (see
// btn-sud-save's handler below) - not a schedule update to
// render, save it to disk instead.
if (sudSavePending) {
sudSavePending = false;
downloadJson(msg.Json);
} else {
updateSudForecast(msg.Json);
}
} else if (key === 'State') {
const newState = msg.State;
const prevState = sudState;
const isRunningNow = RUNNING_STATES.has(newState);
if (isRunningNow && !wasRunning) {
closedLoop = true;
document.getElementById('closed-loop').checked = true;
updateClosedLoopStatus();
sendMsg('Heater', {ClosedLoop: true});
// Accept the next Soll push from the server even if it
// arrives before sudRunning has gone true at this client
// (the two messages are sent as separate tasks server-side,
// so arrival order is non-deterministic in edge cases).
initialTempSollSync = true;
initialHeatrateSync = true;
elapsed = 0;
energyByStep = {};
energyCurrent = 0;
}
wasRunning = isRunningNow;
sudState = newState;
updateControlsEnabled();
if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) {
setUserConfirmPending(true, sudUserMessage);
} else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) {
setUserConfirmPending(false);
}
updateStepPlates();
updateSudActions();
} else if (key === 'UserMessage') {
sudUserMessage = msg.UserMessage;
} else if (key === 'Step') {
const step = msg.Step;
const newIndex = step.Index;
if (sudStepIndex !== null && newIndex !== null && newIndex > sudStepIndex) {
for (let i = sudStepIndex; i < newIndex; i++) {
stepActualFrozen[i] = plotTempIst;
}
}
sudStepIndex = newIndex;
sudStepDescr = step.Descr;
sudStepType = step.Type;
updateStatusLine();
updateStepPlates();
updatePotVisualization();
} else if (key === 'HoldRemaining') {
sudHoldRemaining = msg.HoldRemaining;
updateStatusLine();
updateHeaderCountdown();
} else if (key === 'Elapsed') {
elapsed = msg.Elapsed;
updateStepPlates();
updateStatusLine();
} else if (key === 'Forecast') {
const forecast = msg.Forecast;
forecastStepStarts = Object.fromEntries(forecast.StepStarts);
forecastFinished = forecast.Finished;
forecastFinalT = forecast.T.length ? forecast.T[forecast.T.length - 1] : null;
updateStepPlates();
} else if (key === 'Energy') {
energyByStep = Object.fromEntries(msg.Energy.StepEnergy);
energyCurrent = msg.Energy.Current;
updateStepPlates();
updateStatusLine();
} else if (key === 'Error') {
if (msg.Error) {
alert(msg.Error);
}
}
}
}
function onSystemChanged(msg) {
if ('AmbientTemp' in msg) {
ambientTemp = msg.AmbientTemp;
if (document.activeElement !== document.getElementById('ambient-temp')) {
document.getElementById('ambient-temp').value = msg.AmbientTemp;
}
if (initialPotTempSync) {
document.getElementById('pot-temp').value = msg.AmbientTemp;
initialPotTempSync = false;
}
updateEnvStatusLine();
}
if ('WarpFactor' in msg) {
warpFactor = msg.WarpFactor;
updateEnvStatusLine();
}
if ('PlantSim' in msg) {
document.getElementById('pot-reset-row').classList.toggle('hidden', !msg.PlantSim);
}
if ('Pot' in msg) {
potConfig = msg.Pot;
if (sudEmpty) {
updatePotVisualization();
updateStatusLine();
}
}
}
const HANDLERS = {
Pot: () => {},
Sensor: () => {},
Heater: onHeaterChanged,
Stirrer: onStirrerChanged,
TempCtrl: onTempCtrlChanged,
Sud: onSudChanged,
System: onSystemChanged,
};
// --- Connection management. ---
function setConnected(isConnected) {
connected = isConnected;
document.getElementById('btn-connect').textContent = isConnected ? 'Disconnect' : 'Connect';
const statusEl = document.getElementById('conn-status');
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
statusEl.className = isConnected ? 'status-connected' : 'status-disconnected';
document.getElementById('lcd-pot-row').style.visibility = isConnected ? 'visible' : 'hidden';
document.getElementById('sud-status-line').style.visibility = isConnected ? 'visible' : 'hidden';
document.getElementById('env-status-line').style.visibility = isConnected ? 'visible' : 'hidden';
if (!isConnected) {
// The browser's own link to the server is gone, so whatever
// Heater/Stirrer connection state was last reported is now stale -
// blank it out rather than leave a misleading Connected/
// Disconnected badge showing. Simulated stays unknown too (null,
// not false) since updateDeviceStatus() only hides the buttons
// once the server says so again.
heaterConnected = null;
heaterSimulated = null;
stirrerConnected = null;
stirrerSimulated = null;
updateDeviceStatus('heater', heaterConnected, heaterSimulated);
updateDeviceStatus('stirrer', stirrerConnected, stirrerSimulated);
}
updateSudActions();
}
function connect() {
const statusEl = document.getElementById('conn-status');
const url = document.getElementById('host').value.trim();
let ws_;
try {
ws_ = new WebSocket(url);
} catch (e) {
statusEl.textContent = `Invalid host: ${e.message}`;
statusEl.className = 'status-disconnected';
return;
}
ws = ws_;
statusEl.textContent = 'Connecting…';
ws.onopen = () => {
initialHeatrateSync = true;
initialTempSollSync = true;
initialPotTempSync = true;
closedLoop = true;
updateClosedLoopStatus();
potConfig = {};
setConnected(true);
for (const channel of CHANNELS) {
ws.send(JSON.stringify({'+': channel}));
}
// Fetches whatever schedule is already loaded server-side, exactly
// like client/brewpi_gui.py's Window.connect() - the resulting
// Json/Forecast pair is handled the same way a real Load is
// (see updateSudForecast()'s isNewSchedule check).
sendMsg('Sud', {Save: true});
};
ws.onerror = () => {
statusEl.textContent = `Could not connect to ${url}`;
statusEl.className = 'status-disconnected';
};
ws.onclose = () => {
for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed', 'closed-loop']) {
document.getElementById(id).disabled = false;
}
// Nothing reported over a dead connection can be trusted anymore -
// don't leave the page modally stuck on a pending confirm that'll
// never resolve now (mirrors client/brewpi_gui.py's disconnect
// handling).
setUserConfirmPending(false);
setConnected(false);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
for (const channel of Object.keys(msg)) {
const handler = HANDLERS[channel];
if (handler) {
handler(msg[channel]);
}
}
};
}
function disconnect() {
if (ws) {
ws.close();
}
}
// --- DOM wiring. ---
document.getElementById('host').value = `ws://${location.hostname || 'localhost'}:8765`;
document.getElementById('btn-connect').addEventListener('click', () => {
if (connected) {
disconnect();
} else {
connect();
}
});
document.getElementById('closed-loop').addEventListener('change', (e) => {
closedLoop = e.target.checked;
updateClosedLoopStatus();
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)});
});
document.getElementById('stirrer-speed').addEventListener('input', (e) => {
document.getElementById('stirrer-speed-readout').textContent = e.target.value;
sendMsg('Stirrer', {Speed: Number(e.target.value)});
});
document.getElementById('temp-soll').addEventListener('input', (e) => {
document.getElementById('temp-soll-readout').textContent = e.target.value;
sendMsg('TempCtrl', {Soll: {Temp: Number(e.target.value)}});
});
document.getElementById('heatrate-soll').addEventListener('change', (e) => {
sendMsg('TempCtrl', {Soll: {Rate: Number(e.target.value)}});
});
document.getElementById('ambient-temp').addEventListener('change', (e) => {
sendMsg('System', {AmbientTemp: Number(e.target.value)});
});
document.getElementById('btn-pot-reset').addEventListener('click', () => {
sendMsg('Pot', {Reset: Number(document.getElementById('pot-temp').value)});
});
// New is the same hardcoded empty-doc Load the desktop GUI's
// on_action_sud_new() sends - the server refuses it (like any Load) while a
// run is in progress, surfacing an Error rather than guessing that here.
document.getElementById('btn-sud-new').addEventListener('click', () => {
sendMsg('Sud', {Load: {Name: '', Description: '', pot_mass: 0, pot_material: null, steps: []}});
});
document.getElementById('btn-sud-load').addEventListener('click', () => {
document.getElementById('sud-file-input').click();
});
document.getElementById('sud-file-input').addEventListener('change', (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
sendMsg('Sud', {Load: JSON.parse(reader.result)});
} catch (err) {
alert(`Not a valid Sud file: ${err.message}`);
}
};
reader.readAsText(file);
});
document.getElementById('btn-sud-save').addEventListener('click', () => {
sudSavePending = true;
sendMsg('Sud', {Save: true});
});
document.getElementById('btn-sud-start').addEventListener('click', () => {
sendMsg('Sud', {Start: true});
});
document.getElementById('btn-sud-pause').addEventListener('click', () => {
sendMsg('Sud', {Pause: true});
});
document.getElementById('btn-sud-stop').addEventListener('click', () => {
sendMsg('Sud', {Stop: true});
});
document.getElementById('btn-user-confirm').addEventListener('click', () => {
sendMsg('Sud', {Confirm: true});
});
setInterval(() => {
blinkOn = !blinkOn;
stepPlates.forEach(applyLedColor);
}, 500);
const btnManualToggle = document.getElementById('btn-manual-toggle');
function setManualCollapsed(collapsed) {
document.getElementById('layout').classList.toggle('manual-collapsed', collapsed);
btnManualToggle.textContent = collapsed ? '▶' : '◄';
btnManualToggle.title = collapsed ? 'Expand manual panel' : 'Collapse manual panel';
localStorage.setItem('manualCollapsed', collapsed ? '1' : '0');
}
btnManualToggle.addEventListener('click', () => {
setManualCollapsed(!document.getElementById('layout').classList.contains('manual-collapsed'));
});
setManualCollapsed(localStorage.getItem('manualCollapsed') === '1');
updateSudActions();