From e1d0deb8d2a99467f2af1315614e1c6e076472ef Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 21 Jun 2026 00:15:31 +0200 Subject: [PATCH] Show user_message as a modal dialog when the Sud waits for confirmation Adds a thread-safe path from on_sud_changed() (websocket recv thread) to a modal QMessageBox on the GUI thread via Qt's cross-thread signal/slot queuing, fired when the State transition lands on WAIT_USER - which the schedule is already blocked on server-side, so the dialog just gives it a UI: closing it (OK) sends Confirm. Deliberately scoped to WAIT_USER transitions, not every UserMessage - most steps in sude/sud_0010.json inherit the schedule's default placeholder text ("Put user message here") rather than overriding it, so popping a dialog for every step would mostly show that placeholder. --- client/brewpi_gui.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index ebd4810..dcdfc84 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -19,6 +19,7 @@ import asyncio # str(SudState.X) values (as sent in the 'State' message) that count as # "running" for enabling/disabling the Start/Pause/Stop toolbar actions. SUD_RUNNING_STATES = {"SudState.RAMPING", "SudState.HOLDING", "SudState.WAIT_USER"} +SUD_WAIT_USER_STATE = "SudState.WAIT_USER" SUD_PAUSED_STATE = "SudState.PAUSED" @@ -169,9 +170,16 @@ class SudForecastPlot(FigureCanvasQTAgg): class Window(QtWidgets.QMainWindow, Ui_MainWindow): + # on_sud_changed() runs on the websocket recv thread, but a modal dialog + # must be shown from the GUI thread - a signal/slot crosses that thread + # boundary safely (Qt auto-queues the slot call onto the receiver's + # thread when sender and receiver differ). + user_message_signal = QtCore.pyqtSignal(str) + def __init__(self): QtWidgets.QMainWindow.__init__(self) self.setWindowIcon(QtGui.QIcon('res/beer.png')) + self.user_message_signal.connect(self.on_user_message_signal) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -181,6 +189,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.connected = False self.sud_loaded = False self.sud_state = None + self.sud_user_message = None # time.monotonic() of the last IDLE/DONE -> running transition, used # to position the forecast plot's progress line; None while not # running. sud_paused_total accumulates time spent PAUSED so the @@ -304,6 +313,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): # Nothing reported over a dead connection can be trusted anymore. self.sud_loaded = False self.sud_state = None + self.sud_user_message = None self.sud_start_time = None self.sud_paused_total = 0.0 self.sud_pause_started_at = None @@ -352,6 +362,13 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): def on_action_sud_stop(self): self.msg_sud.send({'Stop': True}) + def on_user_message_signal(self, message): + # Runs on the GUI thread (queued there by the cross-thread signal + # emit in on_sud_changed()). The Sud is already blocked in + # WAIT_USER server-side; closing this dialog is what unblocks it. + QtWidgets.QMessageBox.information(self, "Sud", message) + self.msg_sud.send({'Confirm': True}) + def on_action_sud_save(self): # Pick the destination up front, on the GUI thread - the actual # write happens in on_sud_changed(), which runs off a worker thread @@ -482,6 +499,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_start_time = None self.sud_paused_total = 0.0 self.sud_pause_started_at = None + + if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message: + self.user_message_signal.emit(self.sud_user_message) + elif "UserMessage" in key: + self.sud_user_message = msg['UserMessage'] elif "Step" in key: step = msg['Step'] descr = step.get('Descr')