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
+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;
}