diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 737ceec..e41d99a 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -422,7 +422,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_empty = True self.sud_state = None self.sud_user_message = None - self._confirm_box = None + self.user_confirm_pending = False # Global, not Sud-specific - from the 'System' channel's one-time # startup message. warp_factor defaults to 1.0 (no scaling) until # that arrives, or for a server that doesn't send it at all. @@ -545,6 +545,24 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.cooling_blink_timer = QtCore.QTimer(self) self.cooling_blink_timer.timeout.connect(self.on_cooling_blink) + # Persistent user-confirm control (a Sud step's WAIT_USER, e.g. + # "Bitte Malz einfuellen und bestaetigen") - lives in the top + # toolbar rather than a transient dialog, so it's visible + # regardless of which tab is active. Flashes yellow with the + # step's message while pending (same blink-timer pattern as + # status_label_cooling above); disabled and blank otherwise. + # set_user_confirm_pending() also disables the rest of the GUI + # while pending, forcing this button to be dealt with first. + self.toolBar.addSeparator() + self.label_user_confirm = QtWidgets.QLabel() + self.toolBar.addWidget(self.label_user_confirm) + self.btn_user_confirm = QtWidgets.QPushButton("Confirm") + self.btn_user_confirm.setEnabled(False) + self.btn_user_confirm.clicked.connect(self.on_action_user_confirm) + self.toolBar.addWidget(self.btn_user_confirm) + self.user_confirm_blink_timer = QtCore.QTimer(self) + self.user_confirm_blink_timer.timeout.connect(self.on_user_confirm_blink) + self.update_sud_actions() self.btn_connect.clicked.connect(self.on_btn_connect_clicked) self.actionStart.triggered.connect(self.on_action_sud_start) @@ -727,7 +745,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_empty = True self.sud_state = None self.sud_user_message = None - self._close_confirm_dialog() + self.set_user_confirm_pending(False) for w in (self.Slider_temp_soll, self.doubleSpinBox_heatrate_soll, self.Slider_pwr_soll, self.Slider_speed_soll): w.setEnabled(True) @@ -860,24 +878,47 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): def on_action_sud_stop(self): self.msg_sud.send({'Stop': True}) - def show_user_message(self, message): - if self._confirm_box is not None: + def set_user_confirm_pending(self, pending, message=''): + if pending == self.user_confirm_pending: return - dlg = QtWidgets.QDialog(self) - dlg.setWindowTitle("Sud") - dlg.setWindowModality(QtCore.Qt.NonModal) - layout = QtWidgets.QVBoxLayout(dlg) - layout.addWidget(QtWidgets.QLabel(message)) - btn = QtWidgets.QPushButton("OK") - btn.clicked.connect(lambda: self.msg_sud.send({'Confirm': True})) - layout.addWidget(btn) - self._confirm_box = dlg - dlg.show() + self.user_confirm_pending = pending + if pending: + self.label_user_confirm.setText(message) + self.btn_user_confirm.setEnabled(True) + self.btn_user_confirm.setStyleSheet("background-color: yellow; font-weight: bold;") + self.user_confirm_blink_timer.start(500) + # Modally disabled: nothing else in the GUI should be usable + # until the pending step is actually confirmed - centralwidget + # covers every tab's content; the toolbar/menu actions are + # disabled individually (rather than disabling self.toolBar + # itself) so btn_user_confirm, a sibling widget on the same + # toolbar, stays enabled. + self.centralwidget.setEnabled(False) + for action in (self.actionStart, self.actionPause, self.actionStop, + self.actionSudNew, self.actionSudSave, self.actionSudLoad): + action.setEnabled(False) + else: + self.user_confirm_blink_timer.stop() + self.btn_user_confirm.setStyleSheet("") + self.btn_user_confirm.setEnabled(False) + self.label_user_confirm.setText('') + self.centralwidget.setEnabled(True) + self.actionSudNew.setEnabled(True) + self.actionSudSave.setEnabled(True) + self.actionSudLoad.setEnabled(True) + # Start/Pause/Stop depend on sud_state/sud_empty, not just + # "was disabled for confirm" - let the usual logic pick the + # right one back up rather than assuming all three should + # simply return to enabled. + self.update_sud_actions() - def _close_confirm_dialog(self): - if self._confirm_box is not None: - self._confirm_box.close() - self._confirm_box = None + def on_user_confirm_blink(self): + yellow = self.btn_user_confirm.styleSheet() == "" + self.btn_user_confirm.setStyleSheet( + "background-color: yellow; font-weight: bold;" if yellow else "") + + def on_action_user_confirm(self): + self.msg_sud.send({'Confirm': True}) def on_action_sud_new(self): # Sends an empty schedule via the same Load path Load Sud uses, with @@ -1013,7 +1054,22 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): # Receiving anything at all on this channel means a Sud is loaded # server-side - the schedule it's running may still be coming. self.sud_loaded = True - for key in msg: + # UserMessage must be processed before State: a step's user_message + # is set once, when the step starts (components/sud.py's + # _advance()), well before state later flips to WAIT_USER + # (_finish_step()) - so UserMessage's key was inserted into the + # server's global_state earlier in its lifetime and would precede + # State there. A client connecting fresh while a step is already + # WAIT_USER gets both bundled into this same replay dict; processing + # State first would check self.sud_user_message before this very + # message has set it, silently failing set_user_confirm_pending()'s + # guard and leaving the Confirm button looking merely disabled + # instead of pending - see web/app.js's onSudChanged() for the twin + # fix (same server, same race). + ordered_keys = ['UserMessage'] + [k for k in msg if k != 'UserMessage'] + for key in ordered_keys: + if key not in msg: + continue if "Json" in key: if self.sud_save_path: with open(self.sud_save_path, 'w') as f: @@ -1078,9 +1134,9 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.forecast_plot.show_computing(self.sud_name) if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message: - self.show_user_message(self.sud_user_message) + self.set_user_confirm_pending(True, self.sud_user_message) elif prev_state == SUD_WAIT_USER_STATE and self.sud_state != SUD_WAIT_USER_STATE: - self._close_confirm_dialog() + self.set_user_confirm_pending(False) self._update_step_plates() elif "UserMessage" in key: self.sud_user_message = msg['UserMessage'] diff --git a/web/app.js b/web/app.js index b61eb32..aeb69d6 100644 --- a/web/app.js +++ b/web/app.js @@ -582,12 +582,34 @@ function updateClosedLoopStatus() { statusEl.className = `panel-status ${closedLoop ? 'status-connected' : 'status-disconnected'}`; } -// Mirrors client/brewpi_gui.py's show_user_message(): the Sud is already -// blocked in WAIT_USER server-side - dismissing this dialog (OK or Escape, -// both fire the dialog's 'close' event) is what unblocks it. -function showUserMessage(message) { - document.getElementById('sud-message-text').textContent = message; - document.getElementById('sud-message-dialog').showModal(); +// Mirrors client/brewpi_gui.py's set_user_confirm_pending(): a persistent +// header control rather than a modal dialog. While pending, the Confirm +// button flashes yellow with the step's message and #layout (everything +// except the header) is modally disabled via the 'modally-disabled' class +// (opacity + pointer-events:none - see style.css) so the pending step has +// to be dealt with before anything else. The Sud is already blocked in +// WAIT_USER server-side; clicking Confirm is what unblocks it. +let userConfirmPending = false; +const CONFIRM_DISABLE_IDS = ['btn-connect', 'btn-sud-start', 'btn-sud-pause', 'btn-sud-stop', + 'btn-sud-new', 'btn-sud-load', 'btn-sud-save']; + +function setUserConfirmPending(pending, message = '') { + if (pending === userConfirmPending) return; + userConfirmPending = pending; + const btn = document.getElementById('btn-user-confirm'); + document.getElementById('user-confirm-text').textContent = pending ? message : ''; + btn.disabled = !pending; + btn.classList.toggle('confirm-pending', pending); + document.getElementById('layout').classList.toggle('modally-disabled', pending); + for (const id of CONFIRM_DISABLE_IDS) { + document.getElementById(id).disabled = pending; + } + if (!pending) { + // Start/Pause/Stop depend on sudState/sudEmpty/connected, not just + // "was disabled for confirm" - let the usual logic pick the right + // one back up rather than assuming all should simply re-enable. + updateSudActions(); + } } function downloadJson(doc) { @@ -755,7 +777,19 @@ function onSudChanged(msg) { // Processing Step first would set sudStepIndex before the schedule // exists, then Json would reset it to null (isNewSchedule=true on // first/reloaded connect) with no subsequent Step to restore it. - const keys = ['Json', ...Object.keys(msg).filter(k => k !== 'Json')]; + // + // UserMessage must likewise be processed before State: a step's + // user_message is set once, when the step starts (components/sud.py's + // _advance()), well before state later flips to WAIT_USER + // (_finish_step()) - so UserMessage's key was inserted into + // global_state earlier in the server's lifetime and would otherwise + // precede State here too. A client connecting fresh mid-confirm gets + // both bundled into this same replay message; processing State first + // would check sudUserMessage before this very message has set it, + // silently failing setUserConfirmPending()'s guard and leaving the + // Confirm button looking merely disabled instead of pending - the + // same race the old modal dialog had, just never noticed until now. + const keys = ['Json', 'UserMessage', ...Object.keys(msg).filter(k => k !== 'Json' && k !== 'UserMessage')]; for (const key of keys) { if (!(key in msg)) continue; if (key === 'Json') { @@ -791,10 +825,9 @@ function onSudChanged(msg) { sudState = newState; updateControlsEnabled(); if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) { - showUserMessage(sudUserMessage); + setUserConfirmPending(true, sudUserMessage); } else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) { - const dlg = document.getElementById('sud-message-dialog'); - if (dlg.open) dlg.close(); + setUserConfirmPending(false); } updateStepPlates(); updateSudActions(); @@ -945,6 +978,11 @@ function connect() { for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed', 'closed-loop']) { document.getElementById(id).disabled = false; } + // Nothing reported over a dead connection can be trusted anymore - + // don't leave the page modally stuck on a pending confirm that'll + // never resolve now (mirrors client/brewpi_gui.py's disconnect + // handling). + setUserConfirmPending(false); setConnected(false); }; ws.onmessage = (event) => { @@ -1053,8 +1091,7 @@ document.getElementById('btn-sud-pause').addEventListener('click', () => { document.getElementById('btn-sud-stop').addEventListener('click', () => { sendMsg('Sud', {Stop: true}); }); -document.getElementById('sud-message-dialog').addEventListener('cancel', e => e.preventDefault()); -document.getElementById('sud-message-ok').addEventListener('click', () => { +document.getElementById('btn-user-confirm').addEventListener('click', () => { sendMsg('Sud', {Confirm: true}); }); diff --git a/web/index.html b/web/index.html index 2b7c72d..84b4379 100644 --- a/web/index.html +++ b/web/index.html @@ -30,6 +30,10 @@ +