Add a browser client (web/) alongside the desktop GUI

A new HTML/CSS/JS client, added next to client/brewpi_gui.py rather
than replacing it, speaking the exact same WebSocket pub/sub protocol
- no server-side protocol changes needed. server/brewpi.py gains a
--http-port (default 8080) stdlib http.server.ThreadingHTTPServer,
in its own daemon thread and deliberately decoupled from the existing
asyncio WebSocket server, serving web/ statically.

v1 covers the Manual tab (direct heater/stirrer/controller control)
and the new Progress tab - the two most actionable surfaces, neither
needing a charting library. web/app.js ports the relevant logic from
brewpi_gui.py directly: components/sud.py's _build_step()/
_merge_defaults() for resolving the raw schedule doc, and the
StepPlate/_update_step_plates()/update_status_step_label() logic
behind the Progress tab and status line.

Deliberately deferred rather than stubbed (see web/TODO.md for the
full parity backlog against the Qt GUI): Sud control actions (Start/
Pause/Stop/Confirm), the Automatic tab's forecast plot and the Plot
tab's live strip charts, Sud file management, and auth (matching the
WebSocket server's own existing lack of it).

No new Python dependencies - functools/threading/http.server are all
stdlib.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
This commit is contained in:
2026-06-24 20:12:01 +02:00
co-authored by Claude Sonnet 4.6
parent 2913b41cab
commit 787f02b8dc
7 changed files with 981 additions and 7 deletions
+56 -7
View File
@@ -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://<server-host>: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 -
+17
View File
@@ -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()
+2
View File
@@ -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.
+78
View File
@@ -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 `<dialog>` 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 `<input type="file">` +
`FileReader`+`{"Sud": {"Load": ...}}`; Save to a `Blob`+`<a download>`
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.
+599
View File
@@ -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);
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>BrewPi</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header id="connection-bar">
<label>Host <input id="host" type="text"></label>
<button id="btn-connect">Connect</button>
<span id="conn-status" class="status-disconnected">Disconnected</span>
</header>
<div id="lcd-panel">
<div class="lcd-row lcd-header">
<div class="lcd-label"></div>
<div>Ist</div>
<div>Soll</div>
</div>
<div class="lcd-row">
<div class="lcd-label">Temperature [&deg;C]</div>
<div class="lcd" id="lcd-temp-ist">--</div>
<div class="lcd" id="lcd-temp-soll">--</div>
</div>
<div class="lcd-row">
<div class="lcd-label">Heat rate [&deg;C/min]</div>
<div class="lcd" id="lcd-rate-ist">--</div>
<div class="lcd" id="lcd-rate-soll">--</div>
</div>
<div class="lcd-row">
<div class="lcd-label">Power [W]</div>
<div class="lcd" id="lcd-power-ist">--</div>
<div class="lcd" id="lcd-power-soll">--</div>
</div>
</div>
<nav id="tabs">
<button class="tab-btn active" data-tab="manual">Manual</button>
<button class="tab-btn" data-tab="progress">Progress</button>
</nav>
<main>
<section id="tab-manual" class="tab-content active">
<div class="panel">
<h3>Controller</h3>
<label><input type="checkbox" id="tc-enabled"> Enabled</label>
<label>Temperature [&deg;C]
<input type="range" id="temp-soll" min="0" max="100" step="1" disabled>
<span id="temp-soll-readout">--</span>
</label>
<label>Heat rate [&deg;C/min]
<input type="number" id="heatrate-soll" min="0" max="3" step="0.1" disabled>
</label>
</div>
<div class="panel">
<h3>Heater</h3>
<label><input type="checkbox" id="heater-activate"> Activate</label>
<label>Power [W]
<input type="range" id="heater-power" min="0" max="100" step="1">
<span id="heater-power-readout">--</span>
</label>
</div>
<div class="panel">
<h3>Stirrer</h3>
<label><input type="checkbox" id="stirrer-activate"> Activate</label>
<label>Speed [%]
<input type="range" id="stirrer-speed" min="0" max="100" step="1">
<span id="stirrer-speed-readout">--</span>
</label>
</div>
<div class="panel">
<h3>Environment</h3>
<label>Ambient [&deg;C]
<input type="number" id="ambient-temp" step="0.1">
</label>
<div id="pot-reset-row" class="hidden">
<label>Pot [&deg;C]
<input type="number" id="pot-temp" step="0.1">
</label>
<button id="btn-pot-reset">Reset</button>
</div>
</div>
</section>
<section id="tab-progress" class="tab-content">
<div id="step-plates"></div>
</section>
</main>
<footer>
<div id="sud-status-line"></div>
<div id="env-status-line"></div>
</footer>
<script src="app.js"></script>
</body>
</html>
+130
View File
@@ -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;
}