diff --git a/README.md b/README.md index 3c0371d..43032be 100644 --- a/README.md +++ b/README.md @@ -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 +``/`FileReader` and a `Blob`/`` 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, diff --git a/web/TODO.md b/web/TODO.md index 4d473c8..282ba6e 100644 --- a/web/TODO.md +++ b/web/TODO.md @@ -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 `` works, no library needed) that sends `Confirm` on close. -- [ ] **No schedule management.** Matches README.md's "Browser client" - section, called out there as deliberately deferred rather than missing - by oversight - `on_action_sud_new/_save/_load()` - (`client/brewpi_gui.py:884-912`) use native file dialogs `web/` has no - equivalent of yet. Load maps to `` + - `FileReader`+`{"Sud": {"Load": ...}}`; Save to a `Blob`+`` - of whatever the next `Json` push contains; New is just the same - hardcoded empty-doc `Load` Qt sends, no dialog needed at all. +- [x] **No schedule management.** New/Load/Save are now wired up in + `app.js`: Load is `` + `FileReader` + + `{"Sud": {"Load": ...}}`; Save sends `{"Sud": {"Save": true}}` and + downloads (`Blob`+``) 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. diff --git a/web/app.js b/web/app.js index 0067d31..0044264 100644 --- a/web/app.js +++ b/web/app.js @@ -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(); diff --git a/web/index.html b/web/index.html index dcac411..8b809ea 100644 --- a/web/index.html +++ b/web/index.html @@ -43,6 +43,19 @@
+
+

Sud

+
+ + + + +
+
+ + +
+

Controller

diff --git a/web/style.css b/web/style.css index 5098b87..a02fc0d 100644 --- a/web/style.css +++ b/web/style.css @@ -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 {