Files
brewpi/web/app.js
T
jensandClaude Sonnet 4.6 19d04804dc Add power soll and stirrer RPM to pot SVG; tune flame position
- Power soll label centered below flame area (amber, clears when off)
- Stirrer RPM label next to motor housing (clears when stopped)
- Flame base moved 20 units closer to pot bottom
- Drop Ist/Soll LCD header row to save vertical space

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 16:47:19 +02:00

957 lines
35 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;
// 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,
potMass: pot.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');
if (sudEmpty || sudVolumen <= 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 grainMass = currentStepGrainMass();
const utilizedVolume = currentStepWaterMass() + grainMass * 0.7;
const fillFraction = Math.min(1, Math.max(0, utilizedVolume / sudVolumen));
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');
if (sudSchedule.length === 0) {
el.textContent = '';
return;
}
let text = '';
let grain = sudGrainMass, water = sudWaterMass;
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 pot = sudPotMass;
const massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg`;
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);
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() {
const canRun = connected && !sudEmpty;
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);
}
// 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.
function showUserMessage(message) {
document.getElementById('sud-message-text').textContent = message;
document.getElementById('sud-message-dialog').showModal();
}
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;
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;
}
}
}
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;
}
}
}
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.
const keys = ['Json', ...Object.keys(msg).filter(k => k !== 'Json')];
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;
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) {
showUserMessage(sudUserMessage);
} else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) {
const dlg = document.getElementById('sud-message-dialog');
if (dlg.open) dlg.close();
}
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();
} 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);
}
}
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';
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;
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;
}
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) => {
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)});
});
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('sud-message-dialog').addEventListener('cancel', e => e.preventDefault());
document.getElementById('sud-message-ok').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();