Add Sud New/Load/Save and Start/Stop to the browser client

Ports on_action_sud_new/_save/_load() and on_action_sud_start/_stop()
from client/brewpi_gui.py: Load/Save stand in for the native file
dialogs with <input type="file">/FileReader and a Blob/<a download>,
New sends the same hardcoded empty-doc Load, and Start/Stop are gated
by updateSudActions() mirroring update_sud_actions()'s enabled/disabled
logic. Also fixes the status line's step number being one below what
the Progress tab's plates show for the same step (same bug just fixed
in the desktop client).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
This commit is contained in:
2026-06-25 00:08:30 +02:00
co-authored by Claude Sonnet 4.6
parent 35d33530ef
commit 83fd16e46c
5 changed files with 122 additions and 24 deletions
+8 -6
View File
@@ -538,12 +538,14 @@ 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).
behind the Progress tab and status line. New/Load/Save (a plain
`<input type="file">`/`FileReader` and a `Blob`/`<a download>` standing in
for the desktop app's native file dialogs) and Start/Stop are wired up too
- see `web/TODO.md` for what's still missing (Pause/Confirm, `WAIT_USER`
surfacing, and more). 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.
No authentication - matching the existing WebSocket server's own complete
lack of it, not a new gap. Worth keeping in mind regardless: "browser,
+12 -16
View File
@@ -6,15 +6,13 @@ 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
- [x] **No way to actually run a brew.** ~~There's no Start/Pause/Stop/
Confirm anywhere in `web/index.html`~~ Start/Stop are now wired up
(`btn-sud-start`/`btn-sud-stop` in `app.js`, gated by `updateSudActions()`
mirroring `update_sud_actions()`'s enabled/disabled logic). Still
missing: Pause, and `{"Sud": {"Confirm": true}}` - that one 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.
waiting on one.
- [ ] **`WAIT_USER` never surfaces to the user.** `sudUserMessage` is
tracked in `app.js` but nothing ever shows it - `show_user_message()`
@@ -26,14 +24,12 @@ and checked against the Qt source side by side.
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.
- [x] **No schedule management.** New/Load/Save are now wired up in
`app.js`: Load is `<input type="file">` + `FileReader` +
`{"Sud": {"Load": ...}}`; Save sends `{"Sud": {"Save": true}}` and
downloads (`Blob`+`<a download>`) whichever `Json` push answers it
(tracked via `sudSavePending`, mirroring `brewpi_gui.py`'s
`sud_save_path`); New sends the same hardcoded empty-doc `Load` Qt does.
- [ ] **No temperature-controller state/cooldown indicator.** Qt shows
the raw FSM state (`label_state.setText(msg['State'])` - e.g.
+82 -2
View File
@@ -8,6 +8,7 @@ const CHANNELS = ['Pot', 'Sensor', 'Heater', 'Stirrer', 'TempCtrl', 'Sud', 'Syst
// str(SudState.X) values (as sent in the 'State' message) that count as
// "running" - mirrors client/brewpi_gui.py's SUD_RUNNING_STATES.
const RUNNING_STATES = new Set(['SudState.RAMPING', 'SudState.HOLDING', 'SudState.WAIT_USER']);
const PAUSED_STATE = 'SudState.PAUSED';
let ws = null;
let connected = false;
@@ -62,6 +63,12 @@ let forecastFinished = false;
let forecastFinalT = null;
let energyByStep = {};
let energyCurrent = 0;
// Set right before sending {Sud: {Save: true}} - the next 'Json' push is
// then this request's reply (not a schedule update to render), so it
// triggers a file download instead - mirrors client/brewpi_gui.py's
// sud_save_path flag (on_action_sud_save()/on_sud_changed()'s 'Json'
// branch).
let sudSavePending = false;
let stepPlates = [];
let blinkOn = true;
@@ -265,7 +272,11 @@ function updateStatusLine() {
let text = '';
let grain = sudGrainMass, water = sudWaterMass;
if (sudStepDescr) {
text = `Step ${sudStepIndex}: ${sudStepDescr}`;
// +1: sudStepIndex is the 0-based index Sud.index uses, but the
// Progress tab's step-plates (createStepPlate()) label 1-based -
// match it here, same fix as client/brewpi_gui.py's
// update_status_step_label().
text = `Step ${sudStepIndex + 1}: ${sudStepDescr}`;
if (sudStepType === 'hold') {
const remaining = Math.max(sudHoldRemaining, 0);
text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`;
@@ -285,6 +296,26 @@ function updateStatusLine() {
el.textContent = `${text} ${elapsedText} ${energyText}`;
}
// Mirrors client/brewpi_gui.py's update_sud_actions(): Start also doubles
// as Resume while paused; Stop can still abort a paused run.
function updateSudActions() {
const canRun = connected && !sudEmpty;
const running = canRun && RUNNING_STATES.has(sudState);
const paused = canRun && sudState === PAUSED_STATE;
document.getElementById('btn-sud-start').disabled = !(canRun && !running);
document.getElementById('btn-sud-stop').disabled = !(running || paused);
}
function downloadJson(doc) {
const blob = new Blob([JSON.stringify(doc, null, '\t')], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${doc.Name || 'sud'}.json`;
a.click();
URL.revokeObjectURL(url);
}
function updateEnvStatusLine() {
const el = document.getElementById('env-status-line');
const ambient = ambientTemp !== null ? `${ambientTemp.toFixed(1)}°C` : '-';
@@ -320,6 +351,7 @@ function updateSudForecast(doc) {
}
rebuildStepPlates();
updateStatusLine();
updateSudActions();
document.title = sudName ? `BrewPi - ${sudName}` : 'BrewPi';
}
@@ -419,7 +451,15 @@ function onTempCtrlChanged(msg) {
function onSudChanged(msg) {
for (const key of Object.keys(msg)) {
if (key === 'Json') {
updateSudForecast(msg.Json);
// A standalone reply to a just-sent {Save: true} (see
// btn-sud-save's handler below) - not a schedule update to
// render, save it to disk instead.
if (sudSavePending) {
sudSavePending = false;
downloadJson(msg.Json);
} else {
updateSudForecast(msg.Json);
}
} else if (key === 'State') {
const newState = msg.State;
const isRunningNow = RUNNING_STATES.has(newState);
@@ -431,6 +471,7 @@ function onSudChanged(msg) {
wasRunning = isRunningNow;
sudState = newState;
updateStepPlates();
updateSudActions();
} else if (key === 'UserMessage') {
sudUserMessage = msg.UserMessage;
} else if (key === 'Step') {
@@ -507,6 +548,7 @@ function setConnected(isConnected) {
const statusEl = document.getElementById('conn-status');
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
statusEl.className = isConnected ? 'status-connected' : 'status-disconnected';
updateSudActions();
}
function connect() {
@@ -584,6 +626,42 @@ document.getElementById('btn-pot-reset').addEventListener('click', () => {
sendMsg('Pot', {Reset: Number(document.getElementById('pot-temp').value)});
});
// New is the same hardcoded empty-doc Load the desktop GUI's
// on_action_sud_new() sends - the server refuses it (like any Load) while a
// run is in progress, surfacing an Error rather than guessing that here.
document.getElementById('btn-sud-new').addEventListener('click', () => {
sendMsg('Sud', {Load: {Name: '', Description: '', pot_mass: 0, pot_material: null, steps: []}});
});
document.getElementById('btn-sud-load').addEventListener('click', () => {
document.getElementById('sud-file-input').click();
});
document.getElementById('sud-file-input').addEventListener('change', (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
sendMsg('Sud', {Load: JSON.parse(reader.result)});
} catch (err) {
alert(`Not a valid Sud file: ${err.message}`);
}
};
reader.readAsText(file);
});
document.getElementById('btn-sud-save').addEventListener('click', () => {
sudSavePending = true;
sendMsg('Sud', {Save: true});
});
document.getElementById('btn-sud-start').addEventListener('click', () => {
sendMsg('Sud', {Start: true});
});
document.getElementById('btn-sud-stop').addEventListener('click', () => {
sendMsg('Sud', {Stop: true});
});
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
@@ -597,3 +675,5 @@ setInterval(() => {
blinkOn = !blinkOn;
stepPlates.forEach(applyLedColor);
}, 500);
updateSudActions();
+13
View File
@@ -43,6 +43,19 @@
<main>
<section id="tab-manual" class="tab-content active">
<div class="panel">
<h3>Sud</h3>
<div class="button-row">
<button id="btn-sud-new">New</button>
<button id="btn-sud-load">Load&hellip;</button>
<input type="file" id="sud-file-input" accept="application/json" class="hidden">
<button id="btn-sud-save">Save&hellip;</button>
</div>
<div class="button-row">
<button id="btn-sud-start">Start</button>
<button id="btn-sud-stop">Stop</button>
</div>
</div>
<div class="panel">
<h3>Controller</h3>
<label><input type="checkbox" id="tc-enabled"> Enabled</label>
+7
View File
@@ -84,6 +84,13 @@ nav#tabs {
.hidden { display: none; }
.button-row {
display: flex;
gap: 0.5em;
margin: 0.3em 0;
}
.button-row button:disabled { opacity: 0.5; cursor: default; }
#step-plates { display: flex; flex-direction: column; gap: 0.5em; max-width: 36em; }
.step-plate {