Files
brewpi/web/app.js
T
jensandClaude Sonnet 4.6 bc4a11dcb7 Show floating grain particles in water when grain_mass > 0
35 particles distributed across full pot height, clipped to dynamic
water area via water-clip-rect updated alongside water fill level.
Particle opacity transitions on grain_mass changes per step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGKe8JzTM2Gr1fdx9wYXTi
2026-06-26 22:27:05 +02:00

823 lines
28 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 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 initialPowerSync = true;
let initialSpeedSync = true;
let initialHeatrateSync = true;
let initialTempSollSync = true;
let initialHeaterActivateSync = true;
let initialStirrerActivateSync = true;
let ambientTemp = null;
let warpFactor = 1.0;
let heaterPowerIst = 0;
let heaterMaxPower = 100;
// 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;
if (hours) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${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' : '—'}`;
// 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 total = Math.max(0, endT - startT);
plate.remaining.textContent = formatDuration(Math.max(0, Math.min(total, endT - currentElapsed)));
} 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.actual.textContent = `Actual ${plotTempIst !== null ? plotTempIst.toFixed(1) + ' °C' : '—'}`;
plate.stirrer.textContent = `Stirrer ${stirrerSpeedIst.toFixed(0)} rpm`;
plate.energy.textContent = `Energy ${energyCurrent.toFixed(0)} Wh`;
} else if (i in stepActualFrozen) {
plate.actual.textContent = `Actual ${stepActualFrozen[i].toFixed(1)} °C`;
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.actual.textContent = 'Actual —';
plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`;
plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —';
}
});
}
// --- Stirrer visualization ---
function updateStirrerVisualization() {
const g = document.getElementById('stirrer-blades');
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');
} else {
g.classList.remove('spinning');
}
}
// --- Fire visualization ---
function updateFireVisualization() {
const g = document.getElementById('fire-group');
if (!g) return;
if (heaterPowerIst <= 0) {
g.style.opacity = '0';
g.style.transform = 'scaleY(0.3)';
} else {
const intensity = Math.min(1, heaterPowerIst / heaterMaxPower);
g.style.transform = `scaleY(${(0.45 + 0.55 * intensity).toFixed(2)})`;
g.style.opacity = String((0.6 + 0.4 * intensity).toFixed(2));
}
}
// --- 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' : '--';
// 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');
if (sudEmpty || sudVolumen <= 0) {
waterFillEl.setAttribute('height', '0');
waterWaveEl.setAttribute('d', '');
if (waterClipRect) waterClipRect.setAttribute('height', '0');
if (particlesEl) particlesEl.style.opacity = '0';
return;
}
const fillFraction = Math.min(1, Math.max(0, currentStepWaterMass() / 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 = currentStepGrainMass() > 0 ? '1' : '0';
}
}
// --- 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}`;
}
// 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 = null;
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 === 'Activate') {
if (initialHeaterActivateSync) {
document.getElementById('heater-activate').checked = !!msg.Activate;
initialHeaterActivateSync = false;
}
} else if (key === 'PowerSet') {
document.getElementById('lcd-power-soll').textContent = msg.PowerSet;
} else if (key === 'Power') {
heaterPowerIst = msg.Power;
document.getElementById('lcd-power-ist').textContent = msg.Power;
if (initialPowerSync) {
document.getElementById('heater-power').value = msg.Power;
document.getElementById('heater-power-readout').textContent = msg.Power;
initialPowerSync = false;
}
updateFireVisualization();
} 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 === 'Activate') {
if (initialStirrerActivateSync) {
document.getElementById('stirrer-activate').checked = !!msg.Activate;
initialStirrerActivateSync = false;
}
} else if (key === 'Speed') {
stirrerSpeedIst = msg.Speed;
if (initialSpeedSync) {
document.getElementById('stirrer-speed').value = msg.Speed;
document.getElementById('stirrer-speed-readout').textContent = msg.Speed;
initialSpeedSync = false;
}
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) {
document.getElementById('lcd-temp-soll').textContent = soll.Temp.toFixed(1);
if (initialTempSollSync) {
document.getElementById('temp-soll').value = Math.round(soll.Temp);
initialTempSollSync = false;
}
document.getElementById('temp-soll-readout').textContent = document.getElementById('temp-soll').value;
}
if ('Rate' in soll) {
const rate = soll.Rate;
if ('Current' in rate) {
document.getElementById('lcd-rate-soll').textContent = rate.Current.toFixed(1);
}
if ('Set' in rate && initialHeatrateSync) {
document.getElementById('heatrate-soll').value = rate.Set;
initialHeatrateSync = false;
}
}
} else if (key === 'Enabled') {
const enabled = msg.Enabled;
document.getElementById('tc-enabled').checked = enabled;
document.getElementById('temp-soll').disabled = !enabled;
document.getElementById('heatrate-soll').disabled = !enabled;
}
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) {
for (const key of Object.keys(msg)) {
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) {
elapsed = 0;
energyByStep = {};
energyCurrent = 0;
}
wasRunning = isRunningNow;
sudState = newState;
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;
}
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';
updateSudActions();
}
function connect() {
const host = document.getElementById('host').value;
ws = new WebSocket(host);
ws.onopen = () => {
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.onclose = () => 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}:8765`;
document.getElementById('btn-connect').addEventListener('click', () => {
if (connected) {
disconnect();
} else {
connect();
}
});
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('heater-activate').addEventListener('change', (e) => {
sendMsg('Heater', {Activate: e.target.checked ? 1 : 0});
});
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('stirrer-activate').addEventListener('change', (e) => {
sendMsg('Stirrer', {Activate: e.target.checked ? 1 : 0});
});
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('tc-enabled').addEventListener('change', (e) => {
sendMsg('TempCtrl', {Enable: e.target.checked});
});
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});
});
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach((c) => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
});
});
setInterval(() => {
blinkOn = !blinkOn;
stepPlates.forEach(applyLedColor);
}, 500);
updateSudActions();