// 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'); let text = ''; // Before any schedule is loaded, fall back to config.json's Pot // section (via potConfig) so the status line already shows pot mass/ // water/volume/energy as soon as the System message arrives - same // sudEmpty ? potConfig... : sud... pattern as updatePotVisualization() // above. Step description/remaining-time only exist once a schedule // is actually loaded, so they stay inside the sudEmpty===false branch. let grain = 0, water = potConfig.water_mass || 0; let pot = potConfig.mass || 0; let volumen = potConfig.volumen || 0; if (!sudEmpty) { grain = sudGrainMass; water = sudWaterMass; pot = sudPotMass; volumen = sudVolumen; 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 massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg, Volume ${volumen.toFixed(1)} L`; 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 set_user_confirm_pending(): a persistent // header control rather than a modal dialog. While pending, the Confirm // button flashes yellow with the step's message and #layout (everything // except the header) is modally disabled via the 'modally-disabled' class // (opacity + pointer-events:none - see style.css) so the pending step has // to be dealt with before anything else. The Sud is already blocked in // WAIT_USER server-side; clicking Confirm is what unblocks it. let userConfirmPending = false; const CONFIRM_DISABLE_IDS = ['btn-connect', 'btn-sud-start', 'btn-sud-pause', 'btn-sud-stop', 'btn-sud-new', 'btn-sud-load', 'btn-sud-save']; function setUserConfirmPending(pending, message = '') { if (pending === userConfirmPending) return; userConfirmPending = pending; const btn = document.getElementById('btn-user-confirm'); document.getElementById('user-confirm-text').textContent = pending ? message : ''; btn.disabled = !pending; btn.classList.toggle('confirm-pending', pending); document.getElementById('layout').classList.toggle('modally-disabled', pending); for (const id of CONFIRM_DISABLE_IDS) { document.getElementById(id).disabled = pending; } if (!pending) { // Start/Pause/Stop depend on sudState/sudEmpty/connected, not just // "was disabled for confirm" - let the usual logic pick the right // one back up rather than assuming all should simply re-enable. updateSudActions(); } } 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. // // UserMessage must likewise be processed before State: a step's // user_message is set once, when the step starts (components/sud.py's // _advance()), well before state later flips to WAIT_USER // (_finish_step()) - so UserMessage's key was inserted into // global_state earlier in the server's lifetime and would otherwise // precede State here too. A client connecting fresh mid-confirm gets // both bundled into this same replay message; processing State first // would check sudUserMessage before this very message has set it, // silently failing setUserConfirmPending()'s guard and leaving the // Confirm button looking merely disabled instead of pending - the // same race the old modal dialog had, just never noticed until now. const keys = ['Json', 'UserMessage', ...Object.keys(msg).filter(k => k !== 'Json' && k !== 'UserMessage')]; 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) { setUserConfirmPending(true, sudUserMessage); } else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) { setUserConfirmPending(false); } 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; } // Nothing reported over a dead connection can be trusted anymore - // don't leave the page modally stuck on a pending confirm that'll // never resolve now (mirrors client/brewpi_gui.py's disconnect // handling). setUserConfirmPending(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('btn-user-confirm').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();