Sud._parse_data() already falls back to config.json's Pot section when a loaded sud.json doesn't override 'mass', but neither client actually passed that config through, so displayed pot mass silently read 0 kg whenever a recipe didn't set its own 'pot.mass'. client/brewpi_gui.py: capture the System message's 'Pot' key (already sent by the server, previously ignored) into self.pot_config, and pass it to Sud._parse_data(doc, self.pot_config). web/app.js: parseSudDoc()'s potMass now falls back to the module-level potConfig.mass (already captured from msg.Pot elsewhere, just never consulted here) before defaulting to 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
1071 lines
40 KiB
JavaScript
1071 lines
40 KiB
JavaScript
// BrewPi browser client (v1: Manual + Progress tabs only - see README.md's
|
||
// "Browser client" section). Talks the exact same WebSocket pub/sub
|
||
// protocol as client/brewpi_gui.py - this file mirrors that client's logic
|
||
// directly wherever the two overlap, so see brewpi_gui.py's own comments
|
||
// for the *why* behind anything that looks non-obvious here.
|
||
|
||
const CHANNELS = ['Pot', 'Sensor', 'Heater', 'Stirrer', 'TempCtrl', 'Sud', 'System'];
|
||
// str(SudState.X) values (as sent in the 'State' message) that count as
|
||
// "running" - mirrors client/brewpi_gui.py's SUD_RUNNING_STATES.
|
||
const RUNNING_STATES = new Set(['SudState.RAMPING', 'SudState.HOLDING', 'SudState.WAIT_USER']);
|
||
const PAUSED_STATE = 'SudState.PAUSED';
|
||
const WAIT_USER_STATE = 'SudState.WAIT_USER';
|
||
|
||
let ws = null;
|
||
let connected = false;
|
||
|
||
// Live Manual-tab readouts (TempCtrl/Heater/Stirrer channels).
|
||
let plotTempIst = null;
|
||
let plotTempSoll = null;
|
||
let stirrerSpeedIst = 0;
|
||
// Only the very first authoritative read after connecting is used to set
|
||
// these particular sliders - thereafter they're purely user-controlled.
|
||
// Without this, a slider mid-drag would keep getting snapped back by the
|
||
// server's own *actual* (not commanded) readback - e.g. the heater's
|
||
// discrete power steps/duty-cycling mean 'Power' rarely matches whatever
|
||
// was just commanded exactly. Mirrors brewpi_gui.py's *_initial_update
|
||
// flags (there, needed to avoid echoing a command right back as the same
|
||
// value via Qt's signal/slot machinery; here the DOM itself never echoes
|
||
// a plain .value assignment as an 'input'/'change' event, but the
|
||
// stale-overwrite risk is the same).
|
||
let initialHeatrateSync = true;
|
||
let initialTempSollSync = true;
|
||
let initialPotTempSync = true;
|
||
|
||
let ambientTemp = null;
|
||
let warpFactor = 1.0;
|
||
let heaterPowerIst = 0;
|
||
let heaterPowerSoll = 0;
|
||
let heaterMaxPower = 100;
|
||
// Closed-loop: TC drives heater exclusively. Open-loop: direct power_soll only.
|
||
// Only switchable when not running; auto-reset to true on play.
|
||
let closedLoop = true;
|
||
|
||
// Hardware connection status (Heater/Stirrer channels) - simulated devices
|
||
// are always connected and never expose Connect/Disconnect (see
|
||
// updateDeviceStatus()).
|
||
let heaterConnected = false;
|
||
let heaterSimulated = null;
|
||
let stirrerConnected = false;
|
||
let stirrerSimulated = null;
|
||
|
||
// Pot hardware config from server's Pot section - used for display before
|
||
// any sud is loaded (startup water level, status line).
|
||
let potConfig = {};
|
||
|
||
// Sud state - mirrors the sud_* fields on client/brewpi_gui.py's Window.
|
||
let sudSchedule = [];
|
||
let sudName = '';
|
||
let sudPotMass = 0;
|
||
let sudGrainMass = 0;
|
||
let sudWaterMass = 0;
|
||
let sudVolumen = 0;
|
||
let sudEmpty = true;
|
||
let sudLastLoadedDoc = null;
|
||
let sudState = null;
|
||
let wasRunning = false;
|
||
let sudStepIndex = null;
|
||
let sudStepDescr = null;
|
||
let sudStepType = null;
|
||
let sudHoldRemaining = 0;
|
||
let sudUserMessage = null;
|
||
// Never reset back to "unknown" just because a run finishes - see
|
||
// client/brewpi_gui.py's sud_elapsed_last for why (sud_elapsed_seconds
|
||
// there gates a forecast-plot view this client doesn't have, so that
|
||
// distinction doesn't apply here - this is simply always "the last real
|
||
// Elapsed value").
|
||
let elapsed = null;
|
||
let stepActualFrozen = {};
|
||
let forecastStepStarts = {};
|
||
let forecastFinished = false;
|
||
let forecastFinalT = null;
|
||
let energyByStep = {};
|
||
let energyCurrent = 0;
|
||
// Set right before sending {Sud: {Save: true}} - the next 'Json' push is
|
||
// then this request's reply (not a schedule update to render), so it
|
||
// triggers a file download instead - mirrors client/brewpi_gui.py's
|
||
// sud_save_path flag (on_action_sud_save()/on_sud_changed()'s 'Json'
|
||
// branch).
|
||
let sudSavePending = false;
|
||
|
||
let stepPlates = [];
|
||
let blinkOn = true;
|
||
|
||
function sendMsg(channel, payload) {
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({[channel]: payload}));
|
||
}
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
seconds = Math.max(0, Math.floor(seconds));
|
||
const hours = Math.floor(seconds / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
const secs = seconds % 60;
|
||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||
}
|
||
|
||
// --- Sud schedule resolution - ports components/sud.py's _merge_defaults()/
|
||
// _build_step() verbatim, since the raw doc from 'Json' is unresolved. ---
|
||
|
||
function isPlainObject(value) {
|
||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
|
||
function mergeDefaults(defaults, override) {
|
||
const merged = Object.assign({}, defaults);
|
||
for (const [key, value] of Object.entries(override)) {
|
||
if (isPlainObject(merged[key]) && isPlainObject(value)) {
|
||
merged[key] = mergeDefaults(merged[key], value);
|
||
} else {
|
||
merged[key] = value;
|
||
}
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function buildStep(number, defaults, rawStep) {
|
||
const step = {number: number};
|
||
for (const key of ['descr', 'user_message', 'user_wait_for_continue']) {
|
||
step[key] = (key in rawStep) ? rawStep[key] : defaults[key];
|
||
}
|
||
const rawPot = rawStep.pot || {};
|
||
const defaultPot = defaults.pot || {};
|
||
for (const key of ['grain_mass', 'water_mass']) {
|
||
if (key in rawPot) step[key] = rawPot[key];
|
||
else if (key in defaultPot) step[key] = defaultPot[key];
|
||
}
|
||
// Deliberately not defaulted from default.step - a step omitting it
|
||
// has no new target of its own and keeps whatever the previous step
|
||
// left running (see components/sud.py's _build_step()).
|
||
step.temperature = ('temperature' in rawStep) ? rawStep.temperature : null;
|
||
step.ramp = mergeDefaults(defaults.ramp || {}, rawStep.ramp || {});
|
||
if ('hold' in rawStep) {
|
||
step.hold = mergeDefaults(defaults.hold || {}, rawStep.hold || {});
|
||
}
|
||
return step;
|
||
}
|
||
|
||
function parseSudDoc(doc) {
|
||
const pot = doc.pot || {};
|
||
const stepDefaults = (doc.default && doc.default.step) || {};
|
||
const schedule = (doc.steps || []).map((raw, i) => buildStep(i + 1, stepDefaults, raw));
|
||
return {
|
||
name: doc.Name || '',
|
||
schedule: schedule,
|
||
// Falls back to config.json's Pot.mass (via potConfig, set from
|
||
// the System message) when the loaded doc doesn't override it -
|
||
// mirrors components/sud.py's Sud._parse_data().
|
||
potMass: pot.mass || potConfig.mass || 0,
|
||
grainMass: pot.grain_mass || 0,
|
||
waterMass: pot.water_mass || 0,
|
||
volumen: pot.volumen || 0,
|
||
};
|
||
}
|
||
|
||
// --- Progress tab: one step-plate per schedule step - mirrors
|
||
// client/brewpi_gui.py's StepPlate/_rebuild_step_plates()/
|
||
// _update_step_plates(). ---
|
||
|
||
function createStepPlate(index, step, resolvedTemp) {
|
||
const el = document.createElement('div');
|
||
el.className = 'step-plate';
|
||
|
||
const led = document.createElement('div');
|
||
led.className = 'led';
|
||
const title = document.createElement('div');
|
||
title.className = 'title';
|
||
title.textContent = `Step ${index + 1}: ${step.descr || ''}`;
|
||
const remaining = document.createElement('div');
|
||
remaining.className = 'remaining';
|
||
remaining.textContent = '-:--:--';
|
||
const target = document.createElement('div');
|
||
target.className = 'field span2';
|
||
const actual = document.createElement('div');
|
||
actual.className = 'field';
|
||
const masses = document.createElement('div');
|
||
masses.className = 'field span2';
|
||
const energy = document.createElement('div');
|
||
energy.className = 'field';
|
||
const rate = document.createElement('div');
|
||
rate.className = 'field span2';
|
||
const stirrer = document.createElement('div');
|
||
stirrer.className = 'field';
|
||
el.append(led, title, remaining, target, actual, masses, energy, rate, stirrer);
|
||
|
||
const grain = step.grain_mass || 0;
|
||
const water = step.water_mass || 0;
|
||
masses.textContent = `Grain ${grain.toFixed(2)} kg / Water ${water.toFixed(2)} kg`;
|
||
rate.textContent = `Rate ${((step.ramp && step.ramp.rate) || 0).toFixed(2)} °/min`;
|
||
target.textContent = `Target ${resolvedTemp !== null ? resolvedTemp.toFixed(1) + ' °C' : '—'}`;
|
||
actual.textContent = (step.hold && step.hold.duration != null)
|
||
? `Hold ${formatDuration(step.hold.duration * 60)}` : 'Hold —';
|
||
// The hold phase's speed, not the ramp phase's, as the "configured"
|
||
// fallback shown for an inactive step - see StepPlate.set_step()'s
|
||
// own comment.
|
||
const holdStirrer = (step.hold && step.hold.stirrer) || {};
|
||
const rampStirrer = (step.ramp && step.ramp.stirrer) || {};
|
||
const configuredSpeed = ('speed' in holdStirrer) ? holdStirrer.speed : (rampStirrer.speed || 0);
|
||
|
||
return {el, led, remaining, target, actual, masses, energy, rate, stirrer, ledMode: 'off', configuredSpeed};
|
||
}
|
||
|
||
function rebuildStepPlates() {
|
||
const container = document.getElementById('step-plates');
|
||
container.innerHTML = '';
|
||
stepPlates = [];
|
||
let resolvedTemp = null;
|
||
sudSchedule.forEach((step, index) => {
|
||
if (step.temperature !== null && step.temperature !== undefined) {
|
||
resolvedTemp = step.temperature;
|
||
}
|
||
const plate = createStepPlate(index, step, resolvedTemp);
|
||
stepPlates.push(plate);
|
||
container.appendChild(plate.el);
|
||
});
|
||
updateStepPlates();
|
||
}
|
||
|
||
function applyLedColor(plate) {
|
||
if (plate.ledMode === 'done') {
|
||
plate.led.style.background = 'var(--led-green)';
|
||
} else if (plate.ledMode === 'ramp') {
|
||
plate.led.style.background = blinkOn ? 'var(--led-orange)' : 'var(--led-orange-dim)';
|
||
} else if (plate.ledMode === 'hold') {
|
||
plate.led.style.background = blinkOn ? 'var(--led-green)' : 'var(--led-green-dim)';
|
||
} else {
|
||
plate.led.style.background = 'var(--led-off)';
|
||
}
|
||
}
|
||
|
||
function setLed(plate, mode) {
|
||
plate.ledMode = mode;
|
||
applyLedColor(plate);
|
||
}
|
||
|
||
function updateStepPlates() {
|
||
if (stepPlates.length === 0) {
|
||
return;
|
||
}
|
||
const currentElapsed = (elapsed !== null) ? elapsed : 0;
|
||
const n = stepPlates.length;
|
||
stepPlates.forEach((plate, i) => {
|
||
const startT = forecastStepStarts[i];
|
||
let endT = forecastStepStarts[i + 1];
|
||
if (endT === undefined && i === n - 1 && forecastFinished) {
|
||
endT = forecastFinalT;
|
||
}
|
||
if (startT !== undefined && endT !== undefined) {
|
||
const stepTotal = Math.max(0, endT - startT);
|
||
const holdDurS = (sudSchedule[i] && sudSchedule[i].hold)
|
||
? (sudSchedule[i].hold.duration || 0) * 60 : 0;
|
||
const rampTotal = Math.max(0, stepTotal - holdDurS);
|
||
const rampVal = formatDuration(
|
||
Math.max(0, Math.min(rampTotal, endT - currentElapsed - holdDurS)));
|
||
// Active step in ramp phase gets the "ramp" prefix; hold branch
|
||
// overwrites remaining below with hold time instead.
|
||
plate.remaining.textContent =
|
||
(sudStepIndex === i && sudStepType === 'ramp') ? 'Ramp ' + rampVal : rampVal;
|
||
} else {
|
||
plate.remaining.textContent = '-:--:--';
|
||
}
|
||
|
||
if (sudStepIndex === null || i > sudStepIndex) {
|
||
setLed(plate, 'off');
|
||
} else if (i < sudStepIndex) {
|
||
setLed(plate, 'done');
|
||
} else {
|
||
// WAIT_USER/PAUSED don't change sudStepType - it stays
|
||
// whatever phase was actually last active, which is exactly
|
||
// the color this step's LED should keep flashing.
|
||
setLed(plate, (sudStepType === 'ramp' || sudStepType === 'hold') ? sudStepType : 'hold');
|
||
}
|
||
|
||
if (sudStepIndex !== null && i === sudStepIndex) {
|
||
plate.stirrer.textContent = `Stirrer ${stirrerSpeedIst.toFixed(0)} rpm`;
|
||
plate.energy.textContent = `Energy ${energyCurrent.toFixed(0)} Wh`;
|
||
// Active step: Ramp prefix when ramping; Hold time when holding
|
||
// (reuses the same digit area). PAUSED/WAIT_USER keep the last phase.
|
||
if (sudStepType === 'hold') {
|
||
plate.remaining.textContent = 'Hold ' + formatDuration(Math.max(0, sudHoldRemaining));
|
||
}
|
||
} else if (i in stepActualFrozen) {
|
||
plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`;
|
||
plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —';
|
||
} else {
|
||
plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`;
|
||
plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —';
|
||
}
|
||
});
|
||
updateHeaderCountdown();
|
||
}
|
||
|
||
function updateHeaderCountdown() {
|
||
const currentElapsed = (elapsed !== null) ? elapsed : 0;
|
||
const n = sudSchedule.length;
|
||
let rampText = '-:--:--';
|
||
let totalText = '-:--:--';
|
||
let visible = false;
|
||
if (n > 0 && sudStepIndex !== null) {
|
||
visible = true;
|
||
const startT = forecastStepStarts[sudStepIndex];
|
||
let endT = forecastStepStarts[sudStepIndex + 1];
|
||
if (endT === undefined && sudStepIndex === n - 1 && forecastFinished) {
|
||
endT = forecastFinalT;
|
||
}
|
||
if (startT !== undefined && endT !== undefined) {
|
||
const stepTotal = Math.max(0, endT - startT);
|
||
const step = sudSchedule[sudStepIndex];
|
||
const holdDurS = (step && step.hold) ? (step.hold.duration || 0) * 60 : 0;
|
||
const rampTotal = Math.max(0, stepTotal - holdDurS);
|
||
rampText = formatDuration(Math.max(0, Math.min(rampTotal, endT - currentElapsed - holdDurS)));
|
||
}
|
||
if (forecastFinalT !== null && elapsed !== null) {
|
||
totalText = formatDuration(Math.max(0, forecastFinalT - currentElapsed));
|
||
}
|
||
}
|
||
const holdText = (sudStepType === 'hold' && sudHoldRemaining > 0)
|
||
? formatDuration(Math.max(0, sudHoldRemaining)) : '-:--:--';
|
||
document.getElementById('hdr-step-remaining').textContent = rampText;
|
||
document.getElementById('hdr-total-remaining').textContent = totalText;
|
||
document.getElementById('hdr-hold-remaining').textContent = holdText;
|
||
document.getElementById('header-countdown').classList.toggle('hidden', !visible);
|
||
}
|
||
|
||
// --- Stirrer visualization ---
|
||
|
||
function updateStirrerVisualization() {
|
||
const g = document.getElementById('stirrer-blades');
|
||
const particles = document.getElementById('grain-particles');
|
||
if (!g) return;
|
||
if (stirrerSpeedIst > 0) {
|
||
const duration = Math.min(10, 50 / stirrerSpeedIst).toFixed(2);
|
||
g.style.setProperty('--stir-duration', duration + 's');
|
||
g.classList.add('spinning');
|
||
if (particles) particles.classList.add('stirring');
|
||
} else {
|
||
g.classList.remove('spinning');
|
||
if (particles) particles.classList.remove('stirring');
|
||
}
|
||
const rpmLabel = document.getElementById('svg-stirrer-rpm');
|
||
if (rpmLabel) rpmLabel.textContent = stirrerSpeedIst > 0 ? `${Math.round(stirrerSpeedIst)} rpm` : '';
|
||
}
|
||
|
||
// --- Fire visualization ---
|
||
|
||
function updateFireVisualization() {
|
||
const g = document.getElementById('fire-group');
|
||
if (!g) return;
|
||
if (heaterPowerSoll <= 0) {
|
||
g.style.opacity = '0';
|
||
g.removeAttribute('transform');
|
||
} else {
|
||
const intensity = Math.min(1, heaterPowerSoll / heaterMaxPower);
|
||
const minScale = 1200 / heaterMaxPower;
|
||
const scale = Math.max(minScale, intensity);
|
||
g.style.opacity = '1';
|
||
g.setAttribute('transform', `translate(0,228) scale(1,${scale.toFixed(3)}) translate(0,-248)`);
|
||
}
|
||
const powerLabel = document.getElementById('svg-power-soll');
|
||
if (powerLabel) powerLabel.textContent = heaterPowerSoll > 0 ? `${Math.round(heaterPowerSoll)} W` : '';
|
||
}
|
||
|
||
// --- Pot visualization ---
|
||
|
||
function wavePathD(x1, x2, y, amplitude, wavelength) {
|
||
let d = `M ${x1} ${y.toFixed(1)}`;
|
||
for (let x = x1 + 3; x <= x2; x += 3) {
|
||
const yw = y + amplitude * Math.sin(2 * Math.PI * (x - x1) / wavelength);
|
||
d += ` L ${x} ${yw.toFixed(1)}`;
|
||
}
|
||
return d;
|
||
}
|
||
|
||
function currentStepWaterMass() {
|
||
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
|
||
return sudSchedule[sudStepIndex].water_mass || 0;
|
||
}
|
||
return sudWaterMass;
|
||
}
|
||
|
||
function currentStepGrainMass() {
|
||
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
|
||
const step = sudSchedule[sudStepIndex];
|
||
if ('grain_mass' in step) return step.grain_mass;
|
||
}
|
||
return sudGrainMass;
|
||
}
|
||
|
||
function updatePotVisualization() {
|
||
// Thermometer mercury column (0°C = y=174, 100°C = y=28, tube length = 146)
|
||
const tubeBottom = 174;
|
||
const tubeLen = 146;
|
||
const temp = plotTempIst !== null ? Math.max(0, Math.min(100, plotTempIst)) : 0;
|
||
const mercuryH = (temp / 100) * tubeLen;
|
||
document.getElementById('thermo-mercury').setAttribute('y', (tubeBottom - mercuryH).toFixed(1));
|
||
document.getElementById('thermo-mercury').setAttribute('height', mercuryH.toFixed(1));
|
||
document.getElementById('thermo-temp-label').textContent =
|
||
plotTempIst !== null ? plotTempIst.toFixed(1) + ' °C' : '--';
|
||
|
||
// Soll temperature marker
|
||
const sollTick = document.getElementById('thermo-soll-tick');
|
||
const sollLabel = document.getElementById('thermo-soll-label');
|
||
if (plotTempSoll !== null) {
|
||
const sollY = (tubeBottom - Math.max(0, Math.min(100, plotTempSoll)) / 100 * tubeLen).toFixed(1);
|
||
sollTick.setAttribute('y1', sollY);
|
||
sollTick.setAttribute('y2', sollY);
|
||
sollLabel.setAttribute('y', sollY);
|
||
sollLabel.textContent = plotTempSoll.toFixed(0) + '°';
|
||
sollTick.style.display = '';
|
||
sollLabel.style.display = '';
|
||
} else {
|
||
sollTick.style.display = 'none';
|
||
sollLabel.style.display = 'none';
|
||
}
|
||
|
||
// Water fill
|
||
const waterFillEl = document.getElementById('water-fill');
|
||
const waterWaveEl = document.getElementById('water-wave');
|
||
const potInnerTop = 30;
|
||
const potInnerBottom = 172;
|
||
const potInnerH = potInnerBottom - potInnerTop; // 142
|
||
|
||
const particlesEl = document.getElementById('grain-particles');
|
||
const waterClipRect = document.getElementById('water-clip-rect');
|
||
const levelTickEl = document.getElementById('level-tick');
|
||
const levelLabelEl = document.getElementById('level-label');
|
||
|
||
const volumen = sudEmpty ? (potConfig.volumen || 0) : sudVolumen;
|
||
const grainMass = sudEmpty ? 0 : currentStepGrainMass();
|
||
const waterMass = sudEmpty ? (potConfig.water_mass || 0) : currentStepWaterMass();
|
||
|
||
if (volumen <= 0) {
|
||
waterFillEl.setAttribute('height', '0');
|
||
waterWaveEl.setAttribute('d', '');
|
||
if (waterClipRect) waterClipRect.setAttribute('height', '0');
|
||
if (particlesEl) particlesEl.style.opacity = '0';
|
||
if (levelTickEl) levelTickEl.style.display = 'none';
|
||
if (levelLabelEl) levelLabelEl.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const utilizedVolume = waterMass + grainMass * 0.7;
|
||
const fillFraction = Math.min(1, Math.max(0, utilizedVolume / volumen));
|
||
const waterH = fillFraction * potInnerH;
|
||
const waterTop = potInnerBottom - waterH;
|
||
waterFillEl.setAttribute('y', waterTop.toFixed(1));
|
||
waterFillEl.setAttribute('height', waterH.toFixed(1));
|
||
waterWaveEl.setAttribute('d', wavePathD(63, 177, waterTop, 3, 28));
|
||
|
||
if (waterClipRect) {
|
||
waterClipRect.setAttribute('y', waterTop.toFixed(1));
|
||
waterClipRect.setAttribute('height', waterH.toFixed(1));
|
||
}
|
||
if (particlesEl) {
|
||
particlesEl.style.opacity = grainMass > 0 ? '1' : '0';
|
||
}
|
||
if (levelTickEl && levelLabelEl) {
|
||
const ty = waterTop.toFixed(1);
|
||
levelTickEl.setAttribute('y1', ty);
|
||
levelTickEl.setAttribute('y2', ty);
|
||
levelLabelEl.setAttribute('y', ty);
|
||
levelLabelEl.textContent = utilizedVolume.toFixed(1) + ' L';
|
||
levelTickEl.style.display = '';
|
||
levelLabelEl.style.display = '';
|
||
}
|
||
}
|
||
|
||
// --- Status line - mirrors update_status_step_label(). ---
|
||
|
||
function updateStatusLine() {
|
||
const el = document.getElementById('sud-status-line');
|
||
if (sudSchedule.length === 0) {
|
||
const w = potConfig.water_mass || 0;
|
||
el.textContent = w > 0 ? `No schedule – ${w} L water` : '';
|
||
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 && heaterConnected);
|
||
document.getElementById('stirrer-speed').disabled = sudRunning || !stirrerConnected;
|
||
document.getElementById('closed-loop').disabled = sudRunning;
|
||
}
|
||
|
||
// Mirrors client/brewpi_gui.py's update_sud_actions(): Start also doubles
|
||
// as Resume while paused; Stop can still abort a paused run. A Sud can't be
|
||
// (re)started unless both actuators it depends on are connected - mirrors
|
||
// the server-side refusal in tasks/sud.py's SudTask.recv().
|
||
function updateSudActions() {
|
||
const canRun = connected && !sudEmpty && heaterConnected && stirrerConnected;
|
||
const running = canRun && RUNNING_STATES.has(sudState);
|
||
const paused = canRun && sudState === PAUSED_STATE;
|
||
document.getElementById('btn-sud-start').disabled = !(canRun && !running);
|
||
document.getElementById('btn-sud-pause').disabled = !running;
|
||
document.getElementById('btn-sud-stop').disabled = !(running || paused);
|
||
}
|
||
|
||
// Shared by onHeaterChanged/onStirrerChanged - updates the status badge
|
||
// and Connect/Disconnect buttons for one device. Simulated devices are
|
||
// always connected and have nothing to connect/disconnect, so their
|
||
// buttons are hidden entirely rather than just disabled.
|
||
//
|
||
// isConnected is a tri-state: true/false once the server has actually
|
||
// reported it, or null for "unknown" - shown as a blank '---' badge with
|
||
// both buttons disabled, used while the browser itself has no connection
|
||
// to the server at all (see setConnected()) since a stale Connected/
|
||
// Disconnected from before that disconnect would otherwise misleadingly
|
||
// look current.
|
||
function updateDeviceStatus(prefix, isConnected, simulated) {
|
||
const statusEl = document.getElementById(`${prefix}-status`);
|
||
if (isConnected === null) {
|
||
statusEl.textContent = '---';
|
||
statusEl.className = 'panel-status status-disconnected';
|
||
} else {
|
||
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
|
||
statusEl.className = `panel-status ${isConnected ? 'status-connected' : 'status-disconnected'}`;
|
||
}
|
||
const connectBtn = document.getElementById(`btn-${prefix}-connect`);
|
||
const disconnectBtn = document.getElementById(`btn-${prefix}-disconnect`);
|
||
connectBtn.classList.toggle('hidden', !!simulated);
|
||
disconnectBtn.classList.toggle('hidden', !!simulated);
|
||
connectBtn.disabled = isConnected !== false;
|
||
disconnectBtn.disabled = isConnected !== true;
|
||
}
|
||
|
||
// Reflects closedLoop (the Controller panel's Enable toggle) in the
|
||
// Controller heading's status badge - Enabled/green when the TC is
|
||
// driving the heater, Disabled/gray in open-loop.
|
||
function updateClosedLoopStatus() {
|
||
const statusEl = document.getElementById('closed-loop-status');
|
||
statusEl.textContent = closedLoop ? 'Enabled' : 'Disabled';
|
||
statusEl.className = `panel-status ${closedLoop ? 'status-connected' : 'status-disconnected'}`;
|
||
}
|
||
|
||
// Mirrors client/brewpi_gui.py's 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;
|
||
updateClosedLoopStatus();
|
||
updateControlsEnabled();
|
||
} else if (key === 'Capabilities') {
|
||
const power = msg.Capabilities.Power;
|
||
heaterMaxPower = power.Max || 100;
|
||
const slider = document.getElementById('heater-power');
|
||
slider.min = power.Min;
|
||
slider.max = power.Max;
|
||
} else if (key === 'Connected') {
|
||
heaterConnected = msg.Connected;
|
||
updateDeviceStatus('heater', heaterConnected, heaterSimulated);
|
||
updateControlsEnabled();
|
||
updateSudActions();
|
||
} else if (key === 'Simulated') {
|
||
heaterSimulated = msg.Simulated;
|
||
updateDeviceStatus('heater', heaterConnected, heaterSimulated);
|
||
}
|
||
}
|
||
}
|
||
|
||
function onStirrerChanged(msg) {
|
||
for (const key of Object.keys(msg)) {
|
||
if (key === 'Speed') {
|
||
stirrerSpeedIst = msg.Speed;
|
||
document.getElementById('stirrer-speed').value = msg.Speed;
|
||
document.getElementById('stirrer-speed-readout').textContent = msg.Speed;
|
||
updateStirrerVisualization();
|
||
updateStepPlates();
|
||
} else if (key === 'Capabilities') {
|
||
const power = msg.Capabilities.Power;
|
||
const slider = document.getElementById('stirrer-speed');
|
||
slider.min = power.Min;
|
||
slider.max = power.Max;
|
||
} else if (key === 'Connected') {
|
||
stirrerConnected = msg.Connected;
|
||
updateDeviceStatus('stirrer', stirrerConnected, stirrerSimulated);
|
||
updateControlsEnabled();
|
||
updateSudActions();
|
||
} else if (key === 'Simulated') {
|
||
stirrerSimulated = msg.Simulated;
|
||
updateDeviceStatus('stirrer', stirrerConnected, stirrerSimulated);
|
||
}
|
||
}
|
||
}
|
||
|
||
function onTempCtrlChanged(msg) {
|
||
for (const key of Object.keys(msg)) {
|
||
if (key === 'Soll') {
|
||
const soll = msg.Soll;
|
||
if ('Temp' in soll) {
|
||
plotTempSoll = soll.Temp;
|
||
document.getElementById('lcd-temp-soll').textContent = soll.Temp.toFixed(1);
|
||
const sudRunning = RUNNING_STATES.has(sudState);
|
||
if (initialTempSollSync || sudRunning) {
|
||
document.getElementById('temp-soll').value = Math.round(soll.Temp);
|
||
if (!sudRunning) initialTempSollSync = false;
|
||
}
|
||
document.getElementById('temp-soll-readout').textContent = document.getElementById('temp-soll').value;
|
||
updatePotVisualization();
|
||
}
|
||
if ('Rate' in soll) {
|
||
const rate = soll.Rate;
|
||
if ('Current' in rate) {
|
||
document.getElementById('lcd-rate-soll').textContent = rate.Current.toFixed(1);
|
||
}
|
||
const sudRunningHr = RUNNING_STATES.has(sudState);
|
||
if ('Set' in rate && (initialHeatrateSync || sudRunningHr)) {
|
||
document.getElementById('heatrate-soll').value = rate.Set;
|
||
if (!sudRunningHr) initialHeatrateSync = false;
|
||
}
|
||
}
|
||
}
|
||
if (key === 'Ist') {
|
||
const ist = msg.Ist;
|
||
if ('Temp' in ist) {
|
||
plotTempIst = ist.Temp;
|
||
document.getElementById('lcd-temp-ist').textContent = ist.Temp.toFixed(1);
|
||
updateStepPlates();
|
||
updatePotVisualization();
|
||
}
|
||
if ('Rate' in ist) {
|
||
document.getElementById('lcd-rate-ist').textContent = ist.Rate.toFixed(1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function onSudChanged(msg) {
|
||
// Json must be processed before Step: both can arrive in the same
|
||
// global-state replay message, but Step was inserted into global_state
|
||
// first (at brew start), so it precedes Json in server key order.
|
||
// Processing Step first would set sudStepIndex before the schedule
|
||
// exists, then Json would reset it to null (isNewSchedule=true on
|
||
// first/reloaded connect) with no subsequent Step to restore it.
|
||
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;
|
||
updateClosedLoopStatus();
|
||
sendMsg('Heater', {ClosedLoop: true});
|
||
// Accept the next Soll push from the server even if it
|
||
// arrives before sudRunning has gone true at this client
|
||
// (the two messages are sent as separate tasks server-side,
|
||
// so arrival order is non-deterministic in edge cases).
|
||
initialTempSollSync = true;
|
||
initialHeatrateSync = true;
|
||
elapsed = 0;
|
||
energyByStep = {};
|
||
energyCurrent = 0;
|
||
}
|
||
wasRunning = isRunningNow;
|
||
sudState = newState;
|
||
updateControlsEnabled();
|
||
if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) {
|
||
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();
|
||
updateHeaderCountdown();
|
||
} else if (key === 'Elapsed') {
|
||
elapsed = msg.Elapsed;
|
||
updateStepPlates();
|
||
updateStatusLine();
|
||
} else if (key === 'Forecast') {
|
||
const forecast = msg.Forecast;
|
||
forecastStepStarts = Object.fromEntries(forecast.StepStarts);
|
||
forecastFinished = forecast.Finished;
|
||
forecastFinalT = forecast.T.length ? forecast.T[forecast.T.length - 1] : null;
|
||
updateStepPlates();
|
||
} else if (key === 'Energy') {
|
||
energyByStep = Object.fromEntries(msg.Energy.StepEnergy);
|
||
energyCurrent = msg.Energy.Current;
|
||
updateStepPlates();
|
||
updateStatusLine();
|
||
} else if (key === 'Error') {
|
||
if (msg.Error) {
|
||
alert(msg.Error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function onSystemChanged(msg) {
|
||
if ('AmbientTemp' in msg) {
|
||
ambientTemp = msg.AmbientTemp;
|
||
if (document.activeElement !== document.getElementById('ambient-temp')) {
|
||
document.getElementById('ambient-temp').value = msg.AmbientTemp;
|
||
}
|
||
if (initialPotTempSync) {
|
||
document.getElementById('pot-temp').value = msg.AmbientTemp;
|
||
initialPotTempSync = false;
|
||
}
|
||
updateEnvStatusLine();
|
||
}
|
||
if ('WarpFactor' in msg) {
|
||
warpFactor = msg.WarpFactor;
|
||
updateEnvStatusLine();
|
||
}
|
||
if ('PlantSim' in msg) {
|
||
document.getElementById('pot-reset-row').classList.toggle('hidden', !msg.PlantSim);
|
||
}
|
||
if ('Pot' in msg) {
|
||
potConfig = msg.Pot;
|
||
if (sudEmpty) {
|
||
updatePotVisualization();
|
||
updateStatusLine();
|
||
}
|
||
}
|
||
}
|
||
|
||
const HANDLERS = {
|
||
Pot: () => {},
|
||
Sensor: () => {},
|
||
Heater: onHeaterChanged,
|
||
Stirrer: onStirrerChanged,
|
||
TempCtrl: onTempCtrlChanged,
|
||
Sud: onSudChanged,
|
||
System: onSystemChanged,
|
||
};
|
||
|
||
// --- Connection management. ---
|
||
|
||
function setConnected(isConnected) {
|
||
connected = isConnected;
|
||
document.getElementById('btn-connect').textContent = isConnected ? 'Disconnect' : 'Connect';
|
||
const statusEl = document.getElementById('conn-status');
|
||
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
|
||
statusEl.className = isConnected ? 'status-connected' : 'status-disconnected';
|
||
document.getElementById('lcd-pot-row').style.visibility = isConnected ? 'visible' : 'hidden';
|
||
document.getElementById('sud-status-line').style.visibility = isConnected ? 'visible' : 'hidden';
|
||
document.getElementById('env-status-line').style.visibility = isConnected ? 'visible' : 'hidden';
|
||
if (!isConnected) {
|
||
// The browser's own link to the server is gone, so whatever
|
||
// Heater/Stirrer connection state was last reported is now stale -
|
||
// blank it out rather than leave a misleading Connected/
|
||
// Disconnected badge showing. Simulated stays unknown too (null,
|
||
// not false) since updateDeviceStatus() only hides the buttons
|
||
// once the server says so again.
|
||
heaterConnected = null;
|
||
heaterSimulated = null;
|
||
stirrerConnected = null;
|
||
stirrerSimulated = null;
|
||
updateDeviceStatus('heater', heaterConnected, heaterSimulated);
|
||
updateDeviceStatus('stirrer', stirrerConnected, stirrerSimulated);
|
||
}
|
||
updateSudActions();
|
||
}
|
||
|
||
function connect() {
|
||
const statusEl = document.getElementById('conn-status');
|
||
const url = document.getElementById('host').value.trim();
|
||
let ws_;
|
||
try {
|
||
ws_ = new WebSocket(url);
|
||
} catch (e) {
|
||
statusEl.textContent = `Invalid host: ${e.message}`;
|
||
statusEl.className = 'status-disconnected';
|
||
return;
|
||
}
|
||
ws = ws_;
|
||
statusEl.textContent = 'Connecting…';
|
||
ws.onopen = () => {
|
||
initialHeatrateSync = true;
|
||
initialTempSollSync = true;
|
||
initialPotTempSync = true;
|
||
closedLoop = true;
|
||
updateClosedLoopStatus();
|
||
potConfig = {};
|
||
setConnected(true);
|
||
for (const channel of CHANNELS) {
|
||
ws.send(JSON.stringify({'+': channel}));
|
||
}
|
||
// Fetches whatever schedule is already loaded server-side, exactly
|
||
// like client/brewpi_gui.py's Window.connect() - the resulting
|
||
// Json/Forecast pair is handled the same way a real Load is
|
||
// (see updateSudForecast()'s isNewSchedule check).
|
||
sendMsg('Sud', {Save: true});
|
||
};
|
||
ws.onerror = () => {
|
||
statusEl.textContent = `Could not connect to ${url}`;
|
||
statusEl.className = 'status-disconnected';
|
||
};
|
||
ws.onclose = () => {
|
||
for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed', 'closed-loop']) {
|
||
document.getElementById(id).disabled = false;
|
||
}
|
||
setConnected(false);
|
||
};
|
||
ws.onmessage = (event) => {
|
||
const msg = JSON.parse(event.data);
|
||
for (const channel of Object.keys(msg)) {
|
||
const handler = HANDLERS[channel];
|
||
if (handler) {
|
||
handler(msg[channel]);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function disconnect() {
|
||
if (ws) {
|
||
ws.close();
|
||
}
|
||
}
|
||
|
||
// --- DOM wiring. ---
|
||
|
||
document.getElementById('host').value = `ws://${location.hostname || 'localhost'}:8765`;
|
||
|
||
document.getElementById('btn-connect').addEventListener('click', () => {
|
||
if (connected) {
|
||
disconnect();
|
||
} else {
|
||
connect();
|
||
}
|
||
});
|
||
|
||
document.getElementById('closed-loop').addEventListener('change', (e) => {
|
||
closedLoop = e.target.checked;
|
||
updateClosedLoopStatus();
|
||
sendMsg('Heater', {ClosedLoop: e.target.checked});
|
||
});
|
||
document.getElementById('btn-heater-connect').addEventListener('click', () => {
|
||
sendMsg('Heater', {Connect: true});
|
||
});
|
||
document.getElementById('btn-heater-disconnect').addEventListener('click', () => {
|
||
sendMsg('Heater', {Disconnect: true});
|
||
});
|
||
document.getElementById('btn-stirrer-connect').addEventListener('click', () => {
|
||
sendMsg('Stirrer', {Connect: true});
|
||
});
|
||
document.getElementById('btn-stirrer-disconnect').addEventListener('click', () => {
|
||
sendMsg('Stirrer', {Disconnect: true});
|
||
});
|
||
document.getElementById('heater-power').addEventListener('input', (e) => {
|
||
document.getElementById('heater-power-readout').textContent = e.target.value;
|
||
sendMsg('Heater', {Power: Number(e.target.value)});
|
||
});
|
||
document.getElementById('stirrer-speed').addEventListener('input', (e) => {
|
||
document.getElementById('stirrer-speed-readout').textContent = e.target.value;
|
||
sendMsg('Stirrer', {Speed: Number(e.target.value)});
|
||
});
|
||
document.getElementById('temp-soll').addEventListener('input', (e) => {
|
||
document.getElementById('temp-soll-readout').textContent = e.target.value;
|
||
sendMsg('TempCtrl', {Soll: {Temp: Number(e.target.value)}});
|
||
});
|
||
document.getElementById('heatrate-soll').addEventListener('change', (e) => {
|
||
sendMsg('TempCtrl', {Soll: {Rate: Number(e.target.value)}});
|
||
});
|
||
document.getElementById('ambient-temp').addEventListener('change', (e) => {
|
||
sendMsg('System', {AmbientTemp: Number(e.target.value)});
|
||
});
|
||
document.getElementById('btn-pot-reset').addEventListener('click', () => {
|
||
sendMsg('Pot', {Reset: Number(document.getElementById('pot-temp').value)});
|
||
});
|
||
|
||
// New is the same hardcoded empty-doc Load the desktop GUI's
|
||
// on_action_sud_new() sends - the server refuses it (like any Load) while a
|
||
// run is in progress, surfacing an Error rather than guessing that here.
|
||
document.getElementById('btn-sud-new').addEventListener('click', () => {
|
||
sendMsg('Sud', {Load: {Name: '', Description: '', pot_mass: 0, pot_material: null, steps: []}});
|
||
});
|
||
document.getElementById('btn-sud-load').addEventListener('click', () => {
|
||
document.getElementById('sud-file-input').click();
|
||
});
|
||
document.getElementById('sud-file-input').addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
e.target.value = '';
|
||
if (!file) {
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
sendMsg('Sud', {Load: JSON.parse(reader.result)});
|
||
} catch (err) {
|
||
alert(`Not a valid Sud file: ${err.message}`);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
document.getElementById('btn-sud-save').addEventListener('click', () => {
|
||
sudSavePending = true;
|
||
sendMsg('Sud', {Save: true});
|
||
});
|
||
document.getElementById('btn-sud-start').addEventListener('click', () => {
|
||
sendMsg('Sud', {Start: true});
|
||
});
|
||
document.getElementById('btn-sud-pause').addEventListener('click', () => {
|
||
sendMsg('Sud', {Pause: true});
|
||
});
|
||
document.getElementById('btn-sud-stop').addEventListener('click', () => {
|
||
sendMsg('Sud', {Stop: true});
|
||
});
|
||
document.getElementById('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();
|