// 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 initialPowerSync = true; let initialSpeedSync = true; let initialHeatrateSync = true; let initialTempSollSync = true; let initialHeaterActivateSync = true; let initialStirrerActivateSync = true; let ambientTemp = null; let warpFactor = 1.0; let heaterPowerIst = 0; let heaterPowerSoll = 0; let heaterMaxPower = 100; // Sud state - mirrors the sud_* fields on client/brewpi_gui.py's Window. let sudSchedule = []; let sudName = ''; let sudPotMass = 0; let sudGrainMass = 0; let sudWaterMass = 0; let sudVolumen = 0; let sudEmpty = true; let sudLastLoadedDoc = null; let sudState = null; let wasRunning = false; let sudStepIndex = null; let sudStepDescr = null; let sudStepType = null; let sudHoldRemaining = 0; let sudUserMessage = null; // Never reset back to "unknown" just because a run finishes - see // client/brewpi_gui.py's sud_elapsed_last for why (sud_elapsed_seconds // there gates a forecast-plot view this client doesn't have, so that // distinction doesn't apply here - this is simply always "the last real // Elapsed value"). let elapsed = null; let stepActualFrozen = {}; let forecastStepStarts = {}; let forecastFinished = false; let forecastFinalT = null; let energyByStep = {}; let energyCurrent = 0; // Set right before sending {Sud: {Save: true}} - the next 'Json' push is // then this request's reply (not a schedule update to render), so it // triggers a file download instead - mirrors client/brewpi_gui.py's // sud_save_path flag (on_action_sud_save()/on_sud_changed()'s 'Json' // branch). let sudSavePending = false; let stepPlates = []; let blinkOn = true; function sendMsg(channel, payload) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({[channel]: payload})); } } function formatDuration(seconds) { seconds = Math.max(0, Math.floor(seconds)); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; if (hours) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } return `${minutes}:${String(secs).padStart(2, '0')}`; } // --- Sud schedule resolution - ports components/sud.py's _merge_defaults()/ // _build_step() verbatim, since the raw doc from 'Json' is unresolved. --- function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function mergeDefaults(defaults, override) { const merged = Object.assign({}, defaults); for (const [key, value] of Object.entries(override)) { if (isPlainObject(merged[key]) && isPlainObject(value)) { merged[key] = mergeDefaults(merged[key], value); } else { merged[key] = value; } } return merged; } function buildStep(number, defaults, rawStep) { const step = {number: number}; for (const key of ['descr', 'user_message', 'user_wait_for_continue']) { step[key] = (key in rawStep) ? rawStep[key] : defaults[key]; } const rawPot = rawStep.pot || {}; const defaultPot = defaults.pot || {}; for (const key of ['grain_mass', 'water_mass']) { if (key in rawPot) step[key] = rawPot[key]; else if (key in defaultPot) step[key] = defaultPot[key]; } // Deliberately not defaulted from default.step - a step omitting it // has no new target of its own and keeps whatever the previous step // left running (see components/sud.py's _build_step()). step.temperature = ('temperature' in rawStep) ? rawStep.temperature : null; step.ramp = mergeDefaults(defaults.ramp || {}, rawStep.ramp || {}); if ('hold' in rawStep) { step.hold = mergeDefaults(defaults.hold || {}, rawStep.hold || {}); } return step; } function parseSudDoc(doc) { const pot = doc.pot || {}; const stepDefaults = (doc.default && doc.default.step) || {}; const schedule = (doc.steps || []).map((raw, i) => buildStep(i + 1, stepDefaults, raw)); return { name: doc.Name || '', schedule: schedule, potMass: pot.mass || 0, grainMass: pot.grain_mass || 0, waterMass: pot.water_mass || 0, volumen: pot.volumen || 0, }; } // --- Progress tab: one step-plate per schedule step - mirrors // client/brewpi_gui.py's StepPlate/_rebuild_step_plates()/ // _update_step_plates(). --- function createStepPlate(index, step, resolvedTemp) { const el = document.createElement('div'); el.className = 'step-plate'; const led = document.createElement('div'); led.className = 'led'; const title = document.createElement('div'); title.className = 'title'; title.textContent = `Step ${index + 1}: ${step.descr || ''}`; const remaining = document.createElement('div'); remaining.className = 'remaining'; remaining.textContent = '--:--'; const target = document.createElement('div'); target.className = 'field span2'; const actual = document.createElement('div'); actual.className = 'field'; const masses = document.createElement('div'); masses.className = 'field span2'; const energy = document.createElement('div'); energy.className = 'field'; const rate = document.createElement('div'); rate.className = 'field span2'; const stirrer = document.createElement('div'); stirrer.className = 'field'; el.append(led, title, remaining, target, actual, masses, energy, rate, stirrer); const grain = step.grain_mass || 0; const water = step.water_mass || 0; masses.textContent = `Grain ${grain.toFixed(2)} kg / Water ${water.toFixed(2)} kg`; rate.textContent = `Rate ${((step.ramp && step.ramp.rate) || 0).toFixed(2)} °/min`; target.textContent = `Target ${resolvedTemp !== null ? resolvedTemp.toFixed(1) + ' °C' : '—'}`; // The hold phase's speed, not the ramp phase's, as the "configured" // fallback shown for an inactive step - see StepPlate.set_step()'s // own comment. const holdStirrer = (step.hold && step.hold.stirrer) || {}; const rampStirrer = (step.ramp && step.ramp.stirrer) || {}; const configuredSpeed = ('speed' in holdStirrer) ? holdStirrer.speed : (rampStirrer.speed || 0); return {el, led, remaining, target, actual, masses, energy, rate, stirrer, ledMode: 'off', configuredSpeed}; } function rebuildStepPlates() { const container = document.getElementById('step-plates'); container.innerHTML = ''; stepPlates = []; let resolvedTemp = null; sudSchedule.forEach((step, index) => { if (step.temperature !== null && step.temperature !== undefined) { resolvedTemp = step.temperature; } const plate = createStepPlate(index, step, resolvedTemp); stepPlates.push(plate); container.appendChild(plate.el); }); updateStepPlates(); } function applyLedColor(plate) { if (plate.ledMode === 'done') { plate.led.style.background = 'var(--led-green)'; } else if (plate.ledMode === 'ramp') { plate.led.style.background = blinkOn ? 'var(--led-orange)' : 'var(--led-orange-dim)'; } else if (plate.ledMode === 'hold') { plate.led.style.background = blinkOn ? 'var(--led-green)' : 'var(--led-green-dim)'; } else { plate.led.style.background = 'var(--led-off)'; } } function setLed(plate, mode) { plate.ledMode = mode; applyLedColor(plate); } function updateStepPlates() { if (stepPlates.length === 0) { return; } const currentElapsed = (elapsed !== null) ? elapsed : 0; const n = stepPlates.length; stepPlates.forEach((plate, i) => { const startT = forecastStepStarts[i]; let endT = forecastStepStarts[i + 1]; if (endT === undefined && i === n - 1 && forecastFinished) { endT = forecastFinalT; } if (startT !== undefined && endT !== undefined) { const total = Math.max(0, endT - startT); plate.remaining.textContent = formatDuration(Math.max(0, Math.min(total, endT - currentElapsed))); } else { plate.remaining.textContent = '--:--'; } if (sudStepIndex === null || i > sudStepIndex) { setLed(plate, 'off'); } else if (i < sudStepIndex) { setLed(plate, 'done'); } else { // WAIT_USER/PAUSED don't change sudStepType - it stays // whatever phase was actually last active, which is exactly // the color this step's LED should keep flashing. setLed(plate, (sudStepType === 'ramp' || sudStepType === 'hold') ? sudStepType : 'hold'); } if (sudStepIndex !== null && i === sudStepIndex) { plate.actual.textContent = `Actual ${plotTempIst !== null ? plotTempIst.toFixed(1) + ' °C' : '—'}`; plate.stirrer.textContent = `Stirrer ${stirrerSpeedIst.toFixed(0)} rpm`; plate.energy.textContent = `Energy ${energyCurrent.toFixed(0)} Wh`; } else if (i in stepActualFrozen) { plate.actual.textContent = `Actual ${stepActualFrozen[i].toFixed(1)} °C`; plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`; plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —'; } else { plate.actual.textContent = 'Actual —'; plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`; plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —'; } }); } // --- Stirrer visualization --- function updateStirrerVisualization() { const g = document.getElementById('stirrer-blades'); const particles = document.getElementById('grain-particles'); if (!g) return; if (stirrerSpeedIst > 0) { const duration = Math.min(10, 50 / stirrerSpeedIst).toFixed(2); g.style.setProperty('--stir-duration', duration + 's'); g.classList.add('spinning'); if (particles) particles.classList.add('stirring'); } else { g.classList.remove('spinning'); if (particles) particles.classList.remove('stirring'); } } // --- Fire visualization --- function updateFireVisualization() { const g = document.getElementById('fire-group'); const clipRect = document.getElementById('fire-clip-rect'); if (!g) return; const fireBase = 248; // y of flame base (all paths end here) const fireTip = 177; // y of tallest flame tip const fireH = fireBase - fireTip; // 71px total flame height if (heaterPowerSoll <= 0) { g.style.opacity = '0'; clipRect.setAttribute('height', '0'); } else { const intensity = Math.min(1, heaterPowerSoll / heaterMaxPower); // Always show the lower 35% (base), scale upper 65% with intensity const visibleH = (0.35 + 0.65 * intensity) * fireH; const topBuffer = 20; // headroom for flicker scaleY overshoot (~12% of height) clipRect.setAttribute('y', (fireBase - visibleH - topBuffer).toFixed(1)); clipRect.setAttribute('height', (visibleH + topBuffer + 2).toFixed(1)); g.style.opacity = String((0.6 + 0.4 * intensity).toFixed(2)); } } // --- Pot visualization --- function wavePathD(x1, x2, y, amplitude, wavelength) { let d = `M ${x1} ${y.toFixed(1)}`; for (let x = x1 + 3; x <= x2; x += 3) { const yw = y + amplitude * Math.sin(2 * Math.PI * (x - x1) / wavelength); d += ` L ${x} ${yw.toFixed(1)}`; } return d; } function currentStepWaterMass() { if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) { return sudSchedule[sudStepIndex].water_mass || 0; } return sudWaterMass; } function currentStepGrainMass() { if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) { const step = sudSchedule[sudStepIndex]; if ('grain_mass' in step) return step.grain_mass; } return sudGrainMass; } function updatePotVisualization() { // Thermometer mercury column (0°C = y=174, 100°C = y=28, tube length = 146) const tubeBottom = 174; const tubeLen = 146; const temp = plotTempIst !== null ? Math.max(0, Math.min(100, plotTempIst)) : 0; const mercuryH = (temp / 100) * tubeLen; document.getElementById('thermo-mercury').setAttribute('y', (tubeBottom - mercuryH).toFixed(1)); document.getElementById('thermo-mercury').setAttribute('height', mercuryH.toFixed(1)); document.getElementById('thermo-temp-label').textContent = plotTempIst !== null ? plotTempIst.toFixed(1) + ' °C' : '--'; // Soll temperature marker const sollTick = document.getElementById('thermo-soll-tick'); const sollLabel = document.getElementById('thermo-soll-label'); if (plotTempSoll !== null) { const sollY = (tubeBottom - Math.max(0, Math.min(100, plotTempSoll)) / 100 * tubeLen).toFixed(1); sollTick.setAttribute('y1', sollY); sollTick.setAttribute('y2', sollY); sollLabel.setAttribute('y', sollY); sollLabel.textContent = plotTempSoll.toFixed(0) + '°'; sollTick.style.display = ''; sollLabel.style.display = ''; } else { sollTick.style.display = 'none'; sollLabel.style.display = 'none'; } // Water fill const waterFillEl = document.getElementById('water-fill'); const waterWaveEl = document.getElementById('water-wave'); const potInnerTop = 30; const potInnerBottom = 172; const potInnerH = potInnerBottom - potInnerTop; // 142 const particlesEl = document.getElementById('grain-particles'); const waterClipRect = document.getElementById('water-clip-rect'); const levelTickEl = document.getElementById('level-tick'); const levelLabelEl = document.getElementById('level-label'); if (sudEmpty || sudVolumen <= 0) { waterFillEl.setAttribute('height', '0'); waterWaveEl.setAttribute('d', ''); if (waterClipRect) waterClipRect.setAttribute('height', '0'); if (particlesEl) particlesEl.style.opacity = '0'; if (levelTickEl) levelTickEl.style.display = 'none'; if (levelLabelEl) levelLabelEl.style.display = 'none'; return; } const grainMass = currentStepGrainMass(); const utilizedVolume = currentStepWaterMass() + grainMass * 0.7; const fillFraction = Math.min(1, Math.max(0, utilizedVolume / sudVolumen)); const waterH = fillFraction * potInnerH; const waterTop = potInnerBottom - waterH; waterFillEl.setAttribute('y', waterTop.toFixed(1)); waterFillEl.setAttribute('height', waterH.toFixed(1)); waterWaveEl.setAttribute('d', wavePathD(63, 177, waterTop, 3, 28)); if (waterClipRect) { waterClipRect.setAttribute('y', waterTop.toFixed(1)); waterClipRect.setAttribute('height', waterH.toFixed(1)); } if (particlesEl) { particlesEl.style.opacity = grainMass > 0 ? '1' : '0'; } if (levelTickEl && levelLabelEl) { const ty = waterTop.toFixed(1); levelTickEl.setAttribute('y1', ty); levelTickEl.setAttribute('y2', ty); levelLabelEl.setAttribute('y', ty); levelLabelEl.textContent = utilizedVolume.toFixed(1) + ' L'; levelTickEl.style.display = ''; levelLabelEl.style.display = ''; } } // --- Status line - mirrors update_status_step_label(). --- function updateStatusLine() { const el = document.getElementById('sud-status-line'); if (sudSchedule.length === 0) { el.textContent = ''; return; } let text = ''; let grain = sudGrainMass, water = sudWaterMass; if (sudStepDescr) { // +1: sudStepIndex is the 0-based index Sud.index uses, but the // Progress tab's step-plates (createStepPlate()) label 1-based - // match it here, same fix as client/brewpi_gui.py's // update_status_step_label(). text = `Step ${sudStepIndex + 1}: ${sudStepDescr}`; if (sudStepType === 'hold') { const remaining = Math.max(sudHoldRemaining, 0); text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`; } if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) { const step = sudSchedule[sudStepIndex]; grain = step.grain_mass || 0; water = step.water_mass || 0; } } const pot = sudPotMass; const massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg`; text = text ? `${text} ${massText}` : massText; const elapsedText = `Elapsed ${elapsed !== null ? formatDuration(elapsed) : '--:--'}`; const totalEnergyKwh = (Object.values(energyByStep).reduce((a, b) => a + b, 0) + energyCurrent) / 1000.0; const energyText = `Energy ${totalEnergyKwh.toFixed(2)} kWh`; el.textContent = `${text} ${elapsedText} ${energyText}`; } // Mirrors client/brewpi_gui.py's update_sud_actions(): Start also doubles // as Resume while paused; Stop can still abort a paused run. function updateSudActions() { const canRun = connected && !sudEmpty; const running = canRun && RUNNING_STATES.has(sudState); const paused = canRun && sudState === PAUSED_STATE; document.getElementById('btn-sud-start').disabled = !(canRun && !running); document.getElementById('btn-sud-pause').disabled = !running; document.getElementById('btn-sud-stop').disabled = !(running || paused); } // Mirrors client/brewpi_gui.py's show_user_message(): the Sud is already // blocked in WAIT_USER server-side - dismissing this dialog (OK or Escape, // both fire the dialog's 'close' event) is what unblocks it. function showUserMessage(message) { document.getElementById('sud-message-text').textContent = message; document.getElementById('sud-message-dialog').showModal(); } function downloadJson(doc) { const blob = new Blob([JSON.stringify(doc, null, '\t')], {type: 'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${doc.Name || 'sud'}.json`; a.click(); URL.revokeObjectURL(url); } function updateEnvStatusLine() { const el = document.getElementById('env-status-line'); const ambient = ambientTemp !== null ? `${ambientTemp.toFixed(1)}°C` : '-'; el.textContent = `Ambient: ${ambient} Warp: ${warpFactor.toFixed(1)}x`; } function updateSudForecast(doc) { const parsed = parseSudDoc(doc); // A genuinely different schedule resets run-progress state; a // standalone re-fetch of the *same* running one (e.g. the Save sent // on every connect) must not - see client/brewpi_gui.py's // update_sud_forecast() for the bug this avoids. const isNewSchedule = JSON.stringify(doc) !== JSON.stringify(sudLastLoadedDoc); sudLastLoadedDoc = doc; sudName = parsed.name; sudSchedule = parsed.schedule; sudPotMass = parsed.potMass; sudGrainMass = parsed.grainMass; sudWaterMass = parsed.waterMass; sudVolumen = parsed.volumen; sudEmpty = sudSchedule.length === 0; if (isNewSchedule) { sudStepDescr = null; // sudStepIndex is NOT reset here: Step messages are the authoritative // source. If a Step already arrived before this Json (e.g. from the // global-state replay), resetting would leave the schedule built but // the index null, causing grain/level to vanish until the next step // change. The Step handler always overwrites it anyway. sudStepType = null; sudHoldRemaining = 0; forecastStepStarts = {}; forecastFinished = false; forecastFinalT = null; stepActualFrozen = {}; energyByStep = {}; energyCurrent = 0; elapsed = null; } rebuildStepPlates(); updateStatusLine(); updateSudActions(); updatePotVisualization(); document.title = sudName ? `BrewPi - ${sudName}` : 'BrewPi'; } // --- Channel handlers - mirror client/brewpi_gui.py's on_*_changed(). --- function onHeaterChanged(msg) { for (const key of Object.keys(msg)) { if (key === 'Activate') { if (initialHeaterActivateSync) { document.getElementById('heater-activate').checked = !!msg.Activate; initialHeaterActivateSync = false; } } else if (key === 'PowerSet') { heaterPowerSoll = msg.PowerSet; document.getElementById('lcd-power-soll').textContent = msg.PowerSet; updateFireVisualization(); } else if (key === 'Power') { heaterPowerIst = msg.Power; document.getElementById('lcd-power-ist').textContent = msg.Power; if (initialPowerSync) { document.getElementById('heater-power').value = msg.Power; document.getElementById('heater-power-readout').textContent = msg.Power; initialPowerSync = false; } } else if (key === 'Capabilities') { const power = msg.Capabilities.Power; heaterMaxPower = power.Max || 100; const slider = document.getElementById('heater-power'); slider.min = power.Min; slider.max = power.Max; } } } function onStirrerChanged(msg) { for (const key of Object.keys(msg)) { if (key === 'Activate') { if (initialStirrerActivateSync) { document.getElementById('stirrer-activate').checked = !!msg.Activate; initialStirrerActivateSync = false; } } else if (key === 'Speed') { stirrerSpeedIst = msg.Speed; if (initialSpeedSync) { document.getElementById('stirrer-speed').value = msg.Speed; document.getElementById('stirrer-speed-readout').textContent = msg.Speed; initialSpeedSync = false; } updateStirrerVisualization(); updateStepPlates(); } else if (key === 'Capabilities') { const power = msg.Capabilities.Power; const slider = document.getElementById('stirrer-speed'); slider.min = power.Min; slider.max = power.Max; } } } function onTempCtrlChanged(msg) { for (const key of Object.keys(msg)) { if (key === 'Soll') { const soll = msg.Soll; if ('Temp' in soll) { plotTempSoll = soll.Temp; document.getElementById('lcd-temp-soll').textContent = soll.Temp.toFixed(1); if (initialTempSollSync) { document.getElementById('temp-soll').value = Math.round(soll.Temp); initialTempSollSync = false; } document.getElementById('temp-soll-readout').textContent = document.getElementById('temp-soll').value; updatePotVisualization(); } if ('Rate' in soll) { const rate = soll.Rate; if ('Current' in rate) { document.getElementById('lcd-rate-soll').textContent = rate.Current.toFixed(1); } if ('Set' in rate && initialHeatrateSync) { document.getElementById('heatrate-soll').value = rate.Set; initialHeatrateSync = false; } } } else if (key === 'Enabled') { const enabled = msg.Enabled; document.getElementById('tc-enabled').checked = enabled; document.getElementById('temp-soll').disabled = !enabled; document.getElementById('heatrate-soll').disabled = !enabled; } if (key === 'Ist') { const ist = msg.Ist; if ('Temp' in ist) { plotTempIst = ist.Temp; document.getElementById('lcd-temp-ist').textContent = ist.Temp.toFixed(1); updateStepPlates(); updatePotVisualization(); } if ('Rate' in ist) { document.getElementById('lcd-rate-ist').textContent = ist.Rate.toFixed(1); } } } } function onSudChanged(msg) { // 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) { elapsed = 0; energyByStep = {}; energyCurrent = 0; } wasRunning = isRunningNow; sudState = newState; if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) { showUserMessage(sudUserMessage); } else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) { const dlg = document.getElementById('sud-message-dialog'); if (dlg.open) dlg.close(); } updateStepPlates(); updateSudActions(); } else if (key === 'UserMessage') { sudUserMessage = msg.UserMessage; } else if (key === 'Step') { const step = msg.Step; const newIndex = step.Index; if (sudStepIndex !== null && newIndex !== null && newIndex > sudStepIndex) { for (let i = sudStepIndex; i < newIndex; i++) { stepActualFrozen[i] = plotTempIst; } } sudStepIndex = newIndex; sudStepDescr = step.Descr; sudStepType = step.Type; updateStatusLine(); updateStepPlates(); updatePotVisualization(); } else if (key === 'HoldRemaining') { sudHoldRemaining = msg.HoldRemaining; updateStatusLine(); } else if (key === 'Elapsed') { elapsed = msg.Elapsed; updateStepPlates(); updateStatusLine(); } else if (key === 'Forecast') { const forecast = msg.Forecast; forecastStepStarts = Object.fromEntries(forecast.StepStarts); forecastFinished = forecast.Finished; forecastFinalT = forecast.T.length ? forecast.T[forecast.T.length - 1] : null; updateStepPlates(); } else if (key === 'Energy') { energyByStep = Object.fromEntries(msg.Energy.StepEnergy); energyCurrent = msg.Energy.Current; updateStepPlates(); updateStatusLine(); } else if (key === 'Error') { if (msg.Error) { alert(msg.Error); } } } } function onSystemChanged(msg) { if ('AmbientTemp' in msg) { ambientTemp = msg.AmbientTemp; if (document.activeElement !== document.getElementById('ambient-temp')) { document.getElementById('ambient-temp').value = msg.AmbientTemp; } updateEnvStatusLine(); } if ('WarpFactor' in msg) { warpFactor = msg.WarpFactor; updateEnvStatusLine(); } if ('PlantSim' in msg) { document.getElementById('pot-reset-row').classList.toggle('hidden', !msg.PlantSim); } } const HANDLERS = { Pot: () => {}, Sensor: () => {}, Heater: onHeaterChanged, Stirrer: onStirrerChanged, TempCtrl: onTempCtrlChanged, Sud: onSudChanged, System: onSystemChanged, }; // --- Connection management. --- function setConnected(isConnected) { connected = isConnected; document.getElementById('btn-connect').textContent = isConnected ? 'Disconnect' : 'Connect'; const statusEl = document.getElementById('conn-status'); statusEl.textContent = isConnected ? 'Connected' : 'Disconnected'; statusEl.className = isConnected ? 'status-connected' : 'status-disconnected'; document.getElementById('lcd-pot-row').style.visibility = isConnected ? 'visible' : 'hidden'; updateSudActions(); } function connect() { const host = document.getElementById('host').value; ws = new WebSocket(host); ws.onopen = () => { setConnected(true); for (const channel of CHANNELS) { ws.send(JSON.stringify({'+': channel})); } // Fetches whatever schedule is already loaded server-side, exactly // like client/brewpi_gui.py's Window.connect() - the resulting // Json/Forecast pair is handled the same way a real Load is // (see updateSudForecast()'s isNewSchedule check). sendMsg('Sud', {Save: true}); }; ws.onclose = () => setConnected(false); ws.onmessage = (event) => { const msg = JSON.parse(event.data); for (const channel of Object.keys(msg)) { const handler = HANDLERS[channel]; if (handler) { handler(msg[channel]); } } }; } function disconnect() { if (ws) { ws.close(); } } // --- DOM wiring. --- document.getElementById('host').value = `ws://${location.hostname}:8765`; document.getElementById('btn-connect').addEventListener('click', () => { if (connected) { disconnect(); } else { connect(); } }); document.getElementById('heater-power').addEventListener('input', (e) => { document.getElementById('heater-power-readout').textContent = e.target.value; sendMsg('Heater', {Power: Number(e.target.value)}); }); document.getElementById('heater-activate').addEventListener('change', (e) => { sendMsg('Heater', {Activate: e.target.checked ? 1 : 0}); }); document.getElementById('stirrer-speed').addEventListener('input', (e) => { document.getElementById('stirrer-speed-readout').textContent = e.target.value; sendMsg('Stirrer', {Speed: Number(e.target.value)}); }); document.getElementById('stirrer-activate').addEventListener('change', (e) => { sendMsg('Stirrer', {Activate: e.target.checked ? 1 : 0}); }); document.getElementById('temp-soll').addEventListener('input', (e) => { document.getElementById('temp-soll-readout').textContent = e.target.value; sendMsg('TempCtrl', {Soll: {Temp: Number(e.target.value)}}); }); document.getElementById('heatrate-soll').addEventListener('change', (e) => { sendMsg('TempCtrl', {Soll: {Rate: Number(e.target.value)}}); }); document.getElementById('tc-enabled').addEventListener('change', (e) => { sendMsg('TempCtrl', {Enable: e.target.checked}); }); document.getElementById('ambient-temp').addEventListener('change', (e) => { sendMsg('System', {AmbientTemp: Number(e.target.value)}); }); document.getElementById('btn-pot-reset').addEventListener('click', () => { sendMsg('Pot', {Reset: Number(document.getElementById('pot-temp').value)}); }); // New is the same hardcoded empty-doc Load the desktop GUI's // on_action_sud_new() sends - the server refuses it (like any Load) while a // run is in progress, surfacing an Error rather than guessing that here. document.getElementById('btn-sud-new').addEventListener('click', () => { sendMsg('Sud', {Load: {Name: '', Description: '', pot_mass: 0, pot_material: null, steps: []}}); }); document.getElementById('btn-sud-load').addEventListener('click', () => { document.getElementById('sud-file-input').click(); }); document.getElementById('sud-file-input').addEventListener('change', (e) => { const file = e.target.files[0]; e.target.value = ''; if (!file) { return; } const reader = new FileReader(); reader.onload = () => { try { sendMsg('Sud', {Load: JSON.parse(reader.result)}); } catch (err) { alert(`Not a valid Sud file: ${err.message}`); } }; reader.readAsText(file); }); document.getElementById('btn-sud-save').addEventListener('click', () => { sudSavePending = true; sendMsg('Sud', {Save: true}); }); document.getElementById('btn-sud-start').addEventListener('click', () => { sendMsg('Sud', {Start: true}); }); document.getElementById('btn-sud-pause').addEventListener('click', () => { sendMsg('Sud', {Pause: true}); }); document.getElementById('btn-sud-stop').addEventListener('click', () => { sendMsg('Sud', {Stop: true}); }); document.getElementById('sud-message-dialog').addEventListener('cancel', e => e.preventDefault()); document.getElementById('sud-message-ok').addEventListener('click', () => { sendMsg('Sud', {Confirm: true}); }); document.querySelectorAll('.tab-btn').forEach((btn) => { btn.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); document.querySelectorAll('.tab-content').forEach((c) => c.classList.remove('active')); btn.classList.add('active'); document.getElementById('tab-' + btn.dataset.tab).classList.add('active'); }); }); setInterval(() => { blinkOn = !blinkOn; stepPlates.forEach(applyLedColor); }, 500); updateSudActions();