diff --git a/README.md b/README.md index a97effc..3c0371d 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,14 @@ config.json.templ Configuration template - the "sim" component simulation; point Stirrer/Heater at real serial ports and plant_name at a real backend to switch to hardware. + +web/ Browser client - static HTML/CSS/JS (no build + step, no dependencies), speaking the exact + same WebSocket protocol as client/brewpi_gui.py + (see "Browser client" below). Served by + server/brewpi.py itself over plain HTTP, so a + browser anywhere on the network can reach the + rig with nothing installed beyond the browser. ``` ### Data flow @@ -165,7 +173,9 @@ no-op rather than an empty dialog. - Server (`server/requirements.txt`): `numpy`, `scipy`, `matplotlib` (only for `scripts/demos/*`, not the running server itself), `websockets`, `dpath`, and `pyserial`/`spidev` if using real hardware backends. -- Client (`client/requirements.txt`): `PyQt5`. +- Desktop client (`client/requirements.txt`): `PyQt5`. +- Browser client (`web/`): nothing - just a browser. Served by + `server/brewpi.py` itself. Install with: @@ -190,23 +200,28 @@ pip install -r client/requirements.txt ./brewpi.py ``` - This serves the WebSocket on `ws://0.0.0.0:8765`, ticking in real time - (1 simulated second per tick, no speedup) by default. `--dt` (simulated - seconds/tick) and `--sim-warp-factor` (how many of those fit into one - real second) are run-mode knobs, not `config.json` material - a - plant/hardware description shouldn't change just because of how a + This serves the WebSocket on `ws://0.0.0.0:8765` and the browser client + (see "Browser client" below) on `http://0.0.0.0:8080`, ticking in real + time (1 simulated second per tick, no speedup) by default. `--dt` + (simulated seconds/tick) and `--sim-warp-factor` (how many of those fit + into one real second) are run-mode knobs, not `config.json` material - + a plant/hardware description shouldn't change just because of how a particular run happens to be invoked - so they're CLI-only: ```bash ./brewpi.py --sim-warp-factor 50 # 50x speedup, e.g. for dev/testing + ./brewpi.py --http-port 8888 # change the browser client's port ``` -3. Start the GUI client and connect to the server's URI: +3. Either connect with the desktop GUI: ```bash cd client ./brewpi_gui.py ``` + or open `http://:8080/` in any browser (same machine or + anywhere else on the network) for the browser client. + `server/brewpi.sh` shows how this is wired up to run under a `virtualenvwrapper` environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment. @@ -502,6 +517,40 @@ one, since energy is banked per step rather than accumulated as a grand total - summed client-side instead, from every finished step's own Wh total plus whatever the active one has used so far). +### Browser client + +`web/` is a second, independent client - plain HTML/CSS/JS, no build step, +no dependencies - for the same server, added so the rig is reachable from +any browser on the network rather than only from a machine with the PyQt5 +desktop app installed. It speaks the exact same WebSocket pub/sub protocol +`client/brewpi_gui.py` does (same subscribe-on-connect handshake, same +message shapes on every channel), so nothing about the protocol or +`server/brewpi.py`'s data flow needed to change for it to exist - only a +plain static-file HTTP server (`http.server.ThreadingHTTPServer`, stdlib, +in its own daemon thread, `--http-port`, default `8080`) needed adding, +deliberately independent of the existing `websockets`-based asyncio server +so neither can affect the other's behavior or availability. + +v1 covers only the Manual tab (direct heater/stirrer/controller control) +and the Progress tab (above) - the two most actionable surfaces, and +neither needs a charting library. `web/app.js` ports the relevant logic +from `client/brewpi_gui.py` directly: `components/sud.py`'s +`_build_step()`/`_merge_defaults()` (the raw doc from `Sud.Json` is +unresolved - every step still needs `default.step` merged in), and the +`StepPlate`/`_update_step_plates()`/`update_status_step_label()` logic +behind the Progress tab and status line. Deliberately deferred rather than +stubbed: the Automatic tab's forecast-vs-actual plot and the live Plot +tab's strip charts (both need a charting approach - revisit once Manual + +Progress are validated), and Sud file management (New/Load/Save dialogs - +v1 only *displays* whatever schedule the server already has loaded, fetched +via the same `{"Sud": {"Save": true}}` request `Window.connect()` sends). + +No authentication - matching the existing WebSocket server's own complete +lack of it, not a new gap. Worth keeping in mind regardless: "browser, +reachable from anywhere on the network" is a meaningfully wider exposure +than "desktop app, reachable from wherever its process happens to be +running" if this is ever reachable off a trusted LAN. + ## Logging & analysis `tasks/sud_log.py`'s `SudLogTask` records every Sud run's measured data - diff --git a/server/brewpi.py b/server/brewpi.py index aed1c82..b4ac96f 100755 --- a/server/brewpi.py +++ b/server/brewpi.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 import asyncio +import functools import json import os import sys +import threading import time +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from utils import ChangedFloat from ws.message import MessageDispatcher from ws.server.ws_server_multi_user import WsServerMultiUser @@ -49,6 +52,13 @@ if __name__ == '__main__': # otherwise - e.g. a much higher --sim-warp-factor for dev/testing. parser.add_argument("--dt", type=float, default=1.0) parser.add_argument("--sim-warp-factor", type=float, default=1.0) + # Serves web/ (the browser client - see web/README or the project + # README's "Browser client" section) so a plain browser can reach this + # same rig remotely, no desktop GUI required. A stdlib HTTP server in + # its own thread, deliberately independent of the asyncio WebSocket + # server below (ws/server/ws_server.py) - neither can affect the + # other's behavior or availability. + parser.add_argument("--http-port", type=int, default=8080) args = parser.parse_args() @@ -173,4 +183,11 @@ if __name__ == '__main__': h_dispatcher = taskmgr.start() h_server = server.listen("0.0.0.0", 8765) asyncio.gather(h_dispatcher, h_server) + + web_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "web") + http_handler = functools.partial(SimpleHTTPRequestHandler, directory=web_dir) + http_server = ThreadingHTTPServer(("0.0.0.0", args.http_port), http_handler) + threading.Thread(target=http_server.serve_forever, daemon=True).start() + print("Serving {} on http://0.0.0.0:{}".format(web_dir, args.http_port)) + server.run_forever() diff --git a/server/requirements.txt b/server/requirements.txt index 1432349..8fa23ab 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -3,3 +3,5 @@ scipy matplotlib websockets==10.4 dpath +# web/ (the browser client) needs none of these - it's static HTML/JS +# served straight from the stdlib's http.server, no new dependency. diff --git a/web/TODO.md b/web/TODO.md new file mode 100644 index 0000000..4d473c8 --- /dev/null +++ b/web/TODO.md @@ -0,0 +1,78 @@ +# web/ (browser client) vs. client/brewpi_gui.py parity backlog + +`web/app.js` ported the Manual tab's direct controls and the Progress tab +wholesale from `client/brewpi_gui.py` (see README.md's "Browser client"). +Everything below is what that v1 pass deliberately left out - either +flagged as deferred in the plan, or only noticed once the port was done +and checked against the Qt source side by side. + +- [ ] **No way to actually run a brew.** There's no Start/Pause/Stop/ + Confirm anywhere in `web/index.html` - `on_action_sud_start()`/ + `on_action_sud_pause()`/`on_action_sud_stop()` (`client/brewpi_gui.py: + 868-875`) are one-line `{"Sud": {"Start"/"Pause"/"Stop": true}}` sends, + trivial to add as buttons; `{"Sud": {"Confirm": true}}` already needs + `sudUserMessage`/`sudState` (next item) to know *when* it's actually + waiting on one. Until this exists, the Progress tab can only watch a + run started from the desktop GUI (or a raw `Sud.Start` send from the + browser console) - it's read-only. + +- [ ] **`WAIT_USER` never surfaces to the user.** `sudUserMessage` is + tracked in `app.js` but nothing ever shows it - `show_user_message()` + (`client/brewpi_gui.py:877-882`) pops a modal the instant `WAIT_USER` is + newly entered (`prev_state != WAIT_USER`) and sends `Confirm` itself + when the user dismisses it. Needs the same "newly entered" edge + detection in `onSudChanged()`'s `State` branch (the `wasRunning` + tracking there is close, but checks running-vs-not, not specifically + the `WAIT_USER` transition) plus a modal (a plain `` works, + no library needed) that sends `Confirm` on close. + +- [ ] **No schedule management.** Matches README.md's "Browser client" + section, called out there as deliberately deferred rather than missing + by oversight - `on_action_sud_new/_save/_load()` + (`client/brewpi_gui.py:884-912`) use native file dialogs `web/` has no + equivalent of yet. Load maps to `` + + `FileReader`+`{"Sud": {"Load": ...}}`; Save to a `Blob`+`` + of whatever the next `Json` push contains; New is just the same + hardcoded empty-doc `Load` Qt sends, no dialog needed at all. + +- [ ] **No temperature-controller state/cooldown indicator.** Qt shows + the raw FSM state (`label_state.setText(msg['State'])` - e.g. + `"States.HOLD"`) and a separate blinking "Cool down" label specifically + for `States.COOL` (`set_tc_cooling()`, `client/brewpi_gui.py:820-829`, + driven by `on_tempctrl_changed()`'s `'State'` branch). `app.js`'s + `onTempCtrlChanged()` never reads `msg.State` at all right now - worth + a small label in the Controller panel, reusing the same 500ms + blink-timer pattern the Progress tab's LEDs already have. + +- [ ] **No transient status notifications.** Qt's status bar shows + short-lived messages on certain events - e.g. `"Sud schedule updated: + {name}"` for 5s on every `Name` push (`client/brewpi_gui.py:1043-1045`). + `web/`'s status line only ever shows the persistent Sud/env summaries; + there's nowhere for one-shot events to surface at all. + +- [ ] **Nothing persists across reloads.** `client/user_config.py` keeps + the last ambient temperature and Sud file-dialog directory in + `~/.config/brewpi/gui.json` across restarts. `web/`'s ambient-temp + field always starts blank until the first `System.AmbientTemp` push; + `localStorage` would be the natural equivalent (and the host field + could remember the last-used server too, which the desktop GUI doesn't + even need since it's one app per machine). + +- [ ] **No reconnect handling.** A dropped WebSocket (`ws.onclose`) just + flips the UI to "Disconnected" and stops - the user has to click + Connect again by hand. `WsClient` (`ws/client/ws_client.py`, used by + the desktop GUI) - check whether it already retries and mirror that, or + add a simple backoff retry loop in `connect()`. + +- [ ] **Automatic tab (forecast-vs-actual plot) and Plot tab (live strip + charts) don't exist.** Flagged in the original plan and in README.md's + "Browser client" section as deferred pending a charting decision, not + an oversight - listed here too so this file alone is a complete picture + of the gap. `SudForecastPlot`/`RealtimePlot` + (`client/brewpi_gui.py:121-258`) are the reference for what each needs + to show. + +- [ ] **No authentication.** Also already called out in README.md - matches + the WebSocket server's own complete lack of it, not a new gap, but + worth a line here too since "browser, reachable from anywhere on the + network" is a wider exposure than the desktop app ever was. diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..0067d31 --- /dev/null +++ b/web/app.js @@ -0,0 +1,599 @@ +// 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']); + +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; + +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) { + text = `Step ${sudStepIndex}: ${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}`; +} + +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(); + 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') { + 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(); + } 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'; +} + +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)}); +}); + +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); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..dcac411 --- /dev/null +++ b/web/index.html @@ -0,0 +1,99 @@ + + + + +BrewPi + + + + +
+ + + Disconnected +
+ +
+
+
+
Ist
+
Soll
+
+
+
Temperature [°C]
+
--
+
--
+
+
+
Heat rate [°C/min]
+
--
+
--
+
+
+
Power [W]
+
--
+
--
+
+
+ + + +
+
+
+

Controller

+ + + +
+
+

Heater

+ + +
+
+

Stirrer

+ + +
+
+

Environment

+ + +
+
+ +
+
+
+
+ +
+
+
+
+ + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..5098b87 --- /dev/null +++ b/web/style.css @@ -0,0 +1,130 @@ +:root { + --led-off: #555555; + --led-green: #2ecc71; + --led-green-dim: #1b5e36; + --led-orange: #e67e22; + --led-orange-dim: #7a4111; +} + +body { + font-family: sans-serif; + margin: 0; + padding: 0.5em 1em 4em 1em; + color: #222; +} + +header#connection-bar { + display: flex; + align-items: center; + gap: 1em; + padding-bottom: 0.5em; + border-bottom: 1px solid #ccc; +} + +.status-connected { color: #2ecc71; font-weight: bold; } +.status-disconnected { color: #999; } + +#lcd-panel { + display: grid; + grid-template-columns: 10em 6em 6em; + gap: 0.2em 1em; + margin: 1em 0; + max-width: 30em; +} + +.lcd-row { display: contents; } +.lcd-header { font-weight: bold; } +.lcd-label { color: #555; } +.lcd { + font-family: monospace; + font-size: 1.1em; + background: #fff; + border: 1px solid #2ecc71; + padding: 0.1em 0.3em; + text-align: right; +} + +nav#tabs { + margin-top: 1em; + border-bottom: 1px solid #ccc; +} +.tab-btn { + background: none; + border: 1px solid #ccc; + border-bottom: none; + padding: 0.4em 1em; + cursor: pointer; + position: relative; + top: 1px; +} +.tab-btn.active { + background: #fff; + font-weight: bold; + border-bottom: 1px solid #fff; +} + +.tab-content { display: none; padding: 1em 0; } +.tab-content.active { display: block; } + +.panel { + border: 1px solid #ddd; + border-radius: 4px; + padding: 0.6em 1em; + margin-bottom: 0.8em; + max-width: 30em; +} +.panel h3 { margin: 0 0 0.4em 0; } +.panel label { + display: flex; + align-items: center; + gap: 0.6em; + margin: 0.3em 0; +} +.panel input[type="range"] { flex: 1; } + +.hidden { display: none; } + +#step-plates { display: flex; flex-direction: column; gap: 0.5em; max-width: 36em; } + +.step-plate { + border: 1px solid #ccc; + border-radius: 4px; + padding: 0.5em 0.8em; + display: grid; + grid-template-columns: 1.4em 1fr auto; + gap: 0.2em 0.6em; + align-items: center; +} +.step-plate .led { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--led-off); + border: 1px solid #222; +} +.step-plate .title { + font-weight: bold; + grid-column: 2; +} +.step-plate .remaining { + font-weight: bold; + text-align: right; +} +.step-plate .field { + color: #555; + font-size: 0.95em; +} +.step-plate .span2 { grid-column: span 2; } + +footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #f0f0f0; + border-top: 1px solid #ccc; + padding: 0.3em 1em; + display: flex; + justify-content: space-between; + font-size: 0.9em; +}