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:
@@ -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
|
`_build_step()`/`_merge_defaults()` (the raw doc from `Sud.Json` is
|
||||||
unresolved - every step still needs `default.step` merged in), and the
|
unresolved - every step still needs `default.step` merged in), and the
|
||||||
`StepPlate`/`_update_step_plates()`/`update_status_step_label()` logic
|
`StepPlate`/`_update_step_plates()`/`update_status_step_label()` logic
|
||||||
behind the Progress tab and status line. Deliberately deferred rather than
|
behind the Progress tab and status line. New/Load/Save (a plain
|
||||||
stubbed: the Automatic tab's forecast-vs-actual plot and the live Plot
|
`<input type="file">`/`FileReader` and a `Blob`/`<a download>` standing in
|
||||||
tab's strip charts (both need a charting approach - revisit once Manual +
|
for the desktop app's native file dialogs) and Start/Stop are wired up too
|
||||||
Progress are validated), and Sud file management (New/Load/Save dialogs -
|
- see `web/TODO.md` for what's still missing (Pause/Confirm, `WAIT_USER`
|
||||||
v1 only *displays* whatever schedule the server already has loaded, fetched
|
surfacing, and more). Deliberately deferred rather than stubbed: the
|
||||||
via the same `{"Sud": {"Save": true}}` request `Window.connect()` sends).
|
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
|
No authentication - matching the existing WebSocket server's own complete
|
||||||
lack of it, not a new gap. Worth keeping in mind regardless: "browser,
|
lack of it, not a new gap. Worth keeping in mind regardless: "browser,
|
||||||
|
|||||||
+12
-16
@@ -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
|
flagged as deferred in the plan, or only noticed once the port was done
|
||||||
and checked against the Qt source side by side.
|
and checked against the Qt source side by side.
|
||||||
|
|
||||||
- [ ] **No way to actually run a brew.** There's no Start/Pause/Stop/
|
- [x] **No way to actually run a brew.** ~~There's no Start/Pause/Stop/
|
||||||
Confirm anywhere in `web/index.html` - `on_action_sud_start()`/
|
Confirm anywhere in `web/index.html`~~ Start/Stop are now wired up
|
||||||
`on_action_sud_pause()`/`on_action_sud_stop()` (`client/brewpi_gui.py:
|
(`btn-sud-start`/`btn-sud-stop` in `app.js`, gated by `updateSudActions()`
|
||||||
868-875`) are one-line `{"Sud": {"Start"/"Pause"/"Stop": true}}` sends,
|
mirroring `update_sud_actions()`'s enabled/disabled logic). Still
|
||||||
trivial to add as buttons; `{"Sud": {"Confirm": true}}` already needs
|
missing: Pause, and `{"Sud": {"Confirm": true}}` - that one needs
|
||||||
`sudUserMessage`/`sudState` (next item) to know *when* it's actually
|
`sudUserMessage`/`sudState` (next item) to know *when* it's actually
|
||||||
waiting on one. Until this exists, the Progress tab can only watch a
|
waiting on one.
|
||||||
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
|
- [ ] **`WAIT_USER` never surfaces to the user.** `sudUserMessage` is
|
||||||
tracked in `app.js` but nothing ever shows it - `show_user_message()`
|
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,
|
the `WAIT_USER` transition) plus a modal (a plain `<dialog>` works,
|
||||||
no library needed) that sends `Confirm` on close.
|
no library needed) that sends `Confirm` on close.
|
||||||
|
|
||||||
- [ ] **No schedule management.** Matches README.md's "Browser client"
|
- [x] **No schedule management.** New/Load/Save are now wired up in
|
||||||
section, called out there as deliberately deferred rather than missing
|
`app.js`: Load is `<input type="file">` + `FileReader` +
|
||||||
by oversight - `on_action_sud_new/_save/_load()`
|
`{"Sud": {"Load": ...}}`; Save sends `{"Sud": {"Save": true}}` and
|
||||||
(`client/brewpi_gui.py:884-912`) use native file dialogs `web/` has no
|
downloads (`Blob`+`<a download>`) whichever `Json` push answers it
|
||||||
equivalent of yet. Load maps to `<input type="file">` +
|
(tracked via `sudSavePending`, mirroring `brewpi_gui.py`'s
|
||||||
`FileReader`+`{"Sud": {"Load": ...}}`; Save to a `Blob`+`<a download>`
|
`sud_save_path`); New sends the same hardcoded empty-doc `Load` Qt does.
|
||||||
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
|
- [ ] **No temperature-controller state/cooldown indicator.** Qt shows
|
||||||
the raw FSM state (`label_state.setText(msg['State'])` - e.g.
|
the raw FSM state (`label_state.setText(msg['State'])` - e.g.
|
||||||
|
|||||||
+82
-2
@@ -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
|
// str(SudState.X) values (as sent in the 'State' message) that count as
|
||||||
// "running" - mirrors client/brewpi_gui.py's SUD_RUNNING_STATES.
|
// "running" - mirrors client/brewpi_gui.py's SUD_RUNNING_STATES.
|
||||||
const RUNNING_STATES = new Set(['SudState.RAMPING', 'SudState.HOLDING', 'SudState.WAIT_USER']);
|
const RUNNING_STATES = new Set(['SudState.RAMPING', 'SudState.HOLDING', 'SudState.WAIT_USER']);
|
||||||
|
const PAUSED_STATE = 'SudState.PAUSED';
|
||||||
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
@@ -62,6 +63,12 @@ let forecastFinished = false;
|
|||||||
let forecastFinalT = null;
|
let forecastFinalT = null;
|
||||||
let energyByStep = {};
|
let energyByStep = {};
|
||||||
let energyCurrent = 0;
|
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 stepPlates = [];
|
||||||
let blinkOn = true;
|
let blinkOn = true;
|
||||||
@@ -265,7 +272,11 @@ function updateStatusLine() {
|
|||||||
let text = '';
|
let text = '';
|
||||||
let grain = sudGrainMass, water = sudWaterMass;
|
let grain = sudGrainMass, water = sudWaterMass;
|
||||||
if (sudStepDescr) {
|
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') {
|
if (sudStepType === 'hold') {
|
||||||
const remaining = Math.max(sudHoldRemaining, 0);
|
const remaining = Math.max(sudHoldRemaining, 0);
|
||||||
text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`;
|
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}`;
|
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() {
|
function updateEnvStatusLine() {
|
||||||
const el = document.getElementById('env-status-line');
|
const el = document.getElementById('env-status-line');
|
||||||
const ambient = ambientTemp !== null ? `${ambientTemp.toFixed(1)}°C` : '-';
|
const ambient = ambientTemp !== null ? `${ambientTemp.toFixed(1)}°C` : '-';
|
||||||
@@ -320,6 +351,7 @@ function updateSudForecast(doc) {
|
|||||||
}
|
}
|
||||||
rebuildStepPlates();
|
rebuildStepPlates();
|
||||||
updateStatusLine();
|
updateStatusLine();
|
||||||
|
updateSudActions();
|
||||||
document.title = sudName ? `BrewPi - ${sudName}` : 'BrewPi';
|
document.title = sudName ? `BrewPi - ${sudName}` : 'BrewPi';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +451,15 @@ function onTempCtrlChanged(msg) {
|
|||||||
function onSudChanged(msg) {
|
function onSudChanged(msg) {
|
||||||
for (const key of Object.keys(msg)) {
|
for (const key of Object.keys(msg)) {
|
||||||
if (key === 'Json') {
|
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') {
|
} else if (key === 'State') {
|
||||||
const newState = msg.State;
|
const newState = msg.State;
|
||||||
const isRunningNow = RUNNING_STATES.has(newState);
|
const isRunningNow = RUNNING_STATES.has(newState);
|
||||||
@@ -431,6 +471,7 @@ function onSudChanged(msg) {
|
|||||||
wasRunning = isRunningNow;
|
wasRunning = isRunningNow;
|
||||||
sudState = newState;
|
sudState = newState;
|
||||||
updateStepPlates();
|
updateStepPlates();
|
||||||
|
updateSudActions();
|
||||||
} else if (key === 'UserMessage') {
|
} else if (key === 'UserMessage') {
|
||||||
sudUserMessage = msg.UserMessage;
|
sudUserMessage = msg.UserMessage;
|
||||||
} else if (key === 'Step') {
|
} else if (key === 'Step') {
|
||||||
@@ -507,6 +548,7 @@ function setConnected(isConnected) {
|
|||||||
const statusEl = document.getElementById('conn-status');
|
const statusEl = document.getElementById('conn-status');
|
||||||
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
|
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
|
||||||
statusEl.className = isConnected ? 'status-connected' : 'status-disconnected';
|
statusEl.className = isConnected ? 'status-connected' : 'status-disconnected';
|
||||||
|
updateSudActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
@@ -584,6 +626,42 @@ document.getElementById('btn-pot-reset').addEventListener('click', () => {
|
|||||||
sendMsg('Pot', {Reset: Number(document.getElementById('pot-temp').value)});
|
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) => {
|
document.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
|
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
|
||||||
@@ -597,3 +675,5 @@ setInterval(() => {
|
|||||||
blinkOn = !blinkOn;
|
blinkOn = !blinkOn;
|
||||||
stepPlates.forEach(applyLedColor);
|
stepPlates.forEach(applyLedColor);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
updateSudActions();
|
||||||
|
|||||||
@@ -43,6 +43,19 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section id="tab-manual" class="tab-content active">
|
<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…</button>
|
||||||
|
<input type="file" id="sud-file-input" accept="application/json" class="hidden">
|
||||||
|
<button id="btn-sud-save">Save…</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">
|
<div class="panel">
|
||||||
<h3>Controller</h3>
|
<h3>Controller</h3>
|
||||||
<label><input type="checkbox" id="tc-enabled"> Enabled</label>
|
<label><input type="checkbox" id="tc-enabled"> Enabled</label>
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ nav#tabs {
|
|||||||
|
|
||||||
.hidden { display: none; }
|
.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-plates { display: flex; flex-direction: column; gap: 0.5em; max-width: 36em; }
|
||||||
|
|
||||||
.step-plate {
|
.step-plate {
|
||||||
|
|||||||
Reference in New Issue
Block a user