Temp-soll and heatrate-soll are only editable when closed-loop AND sud is paused or stopped; during a running sud the schedule owns those setpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
918 lines
33 KiB
JavaScript
918 lines
33 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;
|
|
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 —';
|
|
}
|
|
});
|
|
updateHeaderCountdown();
|
|
}
|
|
|
|
function updateHeaderCountdown() {
|
|
const currentElapsed = (elapsed !== null) ? elapsed : 0;
|
|
const n = sudSchedule.length;
|
|
let stepText = '--:--';
|
|
let totalText = '--:--';
|
|
let visible = false;
|
|
if (n > 0 && sudStepIndex !== null) {
|
|
visible = true;
|
|
let endT = forecastStepStarts[sudStepIndex + 1];
|
|
if (endT === undefined && sudStepIndex === n - 1 && forecastFinished) {
|
|
endT = forecastFinalT;
|
|
}
|
|
if (endT !== undefined) {
|
|
stepText = formatDuration(Math.max(0, endT - currentElapsed));
|
|
}
|
|
if (forecastFinalT !== null && elapsed !== null) {
|
|
totalText = formatDuration(Math.max(0, forecastFinalT - currentElapsed));
|
|
}
|
|
}
|
|
document.getElementById('hdr-step-remaining').textContent = stepText;
|
|
document.getElementById('hdr-total-remaining').textContent = totalText;
|
|
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');
|
|
}
|
|
}
|
|
|
|
// --- 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,248) scale(1,${scale.toFixed(3)}) translate(0,-248)`);
|
|
}
|
|
}
|
|
|
|
// --- 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});
|
|
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);
|
|
|
|
updateSudActions();
|