// 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();