Ports on_action_sud_new/_save/_load() and on_action_sud_start/_stop() from client/brewpi_gui.py: Load/Save stand in for the native file dialogs with <input type="file">/FileReader and a Blob/<a download>, New sends the same hardcoded empty-doc Load, and Start/Stop are gated by updateSudActions() mirroring update_sud_actions()'s enabled/disabled logic. Also fixes the status line's step number being one below what the Progress tab's plates show for the same step (same bug just fixed in the desktop client). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
680 lines
24 KiB
JavaScript
680 lines
24 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';
|
|
|
|
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;
|
|
|
|
// 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 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', 'grain_mass', 'water_mass']) {
|
|
step[key] = (key in rawStep) ? rawStep[key] : defaults[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 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: doc.pot_mass || 0,
|
|
grainMass: doc.grain_mass || 0,
|
|
waterMass: doc.water_mass || 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 —';
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- 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-stop').disabled = !(running || paused);
|
|
}
|
|
|
|
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;
|
|
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();
|
|
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') {
|
|
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;
|
|
}
|
|
} else if (key === 'Capabilities') {
|
|
const power = msg.Capabilities.Power;
|
|
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;
|
|
}
|
|
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;
|
|
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;
|
|
}
|
|
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;
|
|
updateStepPlates();
|
|
}
|
|
if ('Rate' in ist) {
|
|
document.getElementById('lcd-rate-ist').textContent = ist.Rate;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 isRunningNow = RUNNING_STATES.has(newState);
|
|
if (isRunningNow && !wasRunning) {
|
|
elapsed = 0;
|
|
energyByStep = {};
|
|
energyCurrent = 0;
|
|
}
|
|
wasRunning = isRunningNow;
|
|
sudState = newState;
|
|
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();
|
|
} 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-stop').addEventListener('click', () => {
|
|
sendMsg('Sud', {Stop: 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();
|