diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 3308a28..df3eb94 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_PAUSED_STATE = "SudState.PAUSED" def _style_axis(ax): @@ -182,8 +183,12 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_state = None # time.monotonic() of the last IDLE/DONE -> running transition, used # to position the forecast plot's progress line; None while not - # running. + # running. sud_paused_total accumulates time spent PAUSED so the + # progress line freezes during a pause instead of jumping ahead; + # sud_pause_started_at is when the current pause began, if any. self.sud_start_time = None + self.sud_paused_total = 0.0 + self.sud_pause_started_at = None self.msg_pot = self.msg_dispatch.msgio_get('Pot') self.msg_sensor = self.msg_dispatch.msgio_get('Sensor') self.msg_heater = self.msg_dispatch.msgio_get('Heater') @@ -202,6 +207,8 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.update_sud_actions() self.btn_connect.clicked.connect(self.on_btn_connect_clicked) self.actionStart.triggered.connect(self.on_action_sud_start) + self.actionPause.triggered.connect(self.on_action_sud_pause) + self.actionStop.triggered.connect(self.on_action_sud_stop) self.actionSudDownload.triggered.connect(self.on_action_sud_download) self.actionSudUpload.triggered.connect(self.on_action_sud_upload) self.Slider_pwr_soll.valueChanged.connect(self.on_slider_pwr_soll_changed) @@ -268,7 +275,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.plot_power_set, self.plot_power_eff) if self.sud_start_time is not None: - self.forecast_plot.set_progress((time.monotonic() - self.sud_start_time) / 60.0) + # While paused, use the moment the pause began as "now" so the + # line freezes instead of drifting ahead of actual progress. + now = self.sud_pause_started_at if self.sud_pause_started_at is not None else time.monotonic() + elapsed_min = (now - self.sud_start_time - self.sud_paused_total) / 60.0 + self.forecast_plot.set_progress(elapsed_min) else: self.forecast_plot.clear_progress() @@ -294,13 +305,18 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_loaded = False self.sud_state = None self.sud_start_time = None + self.sud_paused_total = 0.0 + self.sud_pause_started_at = None self.update_sud_actions() def update_sud_actions(self): running = self.sud_loaded and self.sud_state in SUD_RUNNING_STATES + paused = self.sud_loaded and self.sud_state == SUD_PAUSED_STATE + # Start also doubles as Resume while paused; Stop can still abort a + # paused run, but there's nothing left to Pause once already paused. self.actionStart.setEnabled(self.sud_loaded and not running) self.actionPause.setEnabled(running) - self.actionStop.setEnabled(running) + self.actionStop.setEnabled(running or paused) def on_slider_temp_soll_changed(self, value): print("on_slider_temp_soll_changed {}".format(value)) @@ -330,6 +346,12 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): def on_action_sud_start(self): self.msg_sud.send({'Start': True}) + def on_action_sud_pause(self): + self.msg_sud.send({'Pause': True}) + + def on_action_sud_stop(self): + self.msg_sud.send({'Stop': True}) + def on_action_sud_download(self): # Pick the destination up front, on the GUI thread - the actual # write happens in on_sud_changed(), which runs off a worker thread @@ -445,13 +467,21 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): elif "Name" in key: self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000) elif "State" in key: - running_before = self.sud_state in SUD_RUNNING_STATES + prev_state = self.sud_state self.sud_state = msg['State'] - running_now = self.sud_state in SUD_RUNNING_STATES - if running_now and not running_before: + + if self.sud_state == SUD_PAUSED_STATE and prev_state != SUD_PAUSED_STATE: + self.sud_pause_started_at = time.monotonic() + elif self.sud_state != SUD_PAUSED_STATE and prev_state == SUD_PAUSED_STATE: + self.sud_paused_total += time.monotonic() - self.sud_pause_started_at + self.sud_pause_started_at = None + + if self.sud_state in SUD_RUNNING_STATES and self.sud_start_time is None: self.sud_start_time = time.monotonic() - elif not running_now: + elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE: self.sud_start_time = None + self.sud_paused_total = 0.0 + self.sud_pause_started_at = None elif "Step" in key: step = msg['Step'] descr = step.get('Descr') diff --git a/components/sud.py b/components/sud.py index 13d3fd7..43e13c9 100644 --- a/components/sud.py +++ b/components/sud.py @@ -18,6 +18,7 @@ class SudState(Enum): HOLDING = 2 WAIT_USER = 3 DONE = 4 + PAUSED = 5 def _merge_defaults(default, override): @@ -55,11 +56,8 @@ class Sud(AttributeChange): (self.name, self.description, self.schedule, self.pot_mass, self.pot_material) = self._parse_data(data) - self.index = -1 - self.hold_remaining = 0.0 - self.state = SudState.IDLE - self.step = None - self.user_message = None + self._paused_from = None + self._reset_run_state() @staticmethod def _parse_data(data): @@ -78,6 +76,16 @@ class Sud(AttributeChange): return name, description, schedule, pot_mass, pot_material + def _reset_run_state(self): + """Resets run-time progress back to a freshly-loaded, not-yet-started + IDLE state. Shared by __init__, upload(), and stop().""" + self.index = -1 + self.hold_remaining = 0.0 + self.state = SudState.IDLE + self.step = None + self.user_message = None + self._paused_from = None + def download(self): """Returns the sud.json document currently on disk, for a client to edit and hand back to upload().""" @@ -100,11 +108,7 @@ class Sud(AttributeChange): with open(self.path, 'w') as f: json.dump(data, f, indent='\t') - self.index = -1 - self.hold_remaining = 0.0 - self.state = SudState.IDLE - self.step = None - self.user_message = None + self._reset_run_state() return True def derive_plant_params(self, grain_mass, water_mass): @@ -125,11 +129,29 @@ class Sud(AttributeChange): } def start(self): + """Starts a fresh run from IDLE, or resumes a paused one - whichever + applies. No-op otherwise.""" + if self.state == SudState.PAUSED: + self.state = self._paused_from + self._paused_from = None + return if self.state != SudState.IDLE: return self.index = -1 self._advance() + def pause(self): + """Freezes progress (the hold countdown and ramp-reached checks both + no-op while PAUSED) without losing where we are; start() resumes.""" + if self.state in (SudState.RAMPING, SudState.HOLDING): + self._paused_from = self.state + self.state = SudState.PAUSED + + def stop(self): + """Aborts the run, discarding progress back to IDLE.""" + if self.state not in (SudState.IDLE, SudState.DONE): + self._reset_run_state() + def confirm(self): if self.state == SudState.WAIT_USER: self._advance() diff --git a/tasks/sud.py b/tasks/sud.py index f92b9a6..e3a594a 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -53,7 +53,7 @@ class SudTask(ATask): def on_state_changed(self, value): asyncio.create_task(self.send({'State': str(value)})) - if value == SudState.DONE: + if value in (SudState.DONE, SudState.IDLE): self.stirrer.set_duty_cycle(1.0) self.stirrer.set_speed(0) @@ -69,6 +69,10 @@ class SudTask(ATask): self.sud.start() elif 'Confirm' in pair[0]: self.sud.confirm() + elif 'Pause' in pair[0]: + self.sud.pause() + elif 'Stop' in pair[0]: + self.sud.stop() elif 'Download' in pair[0]: await self.send({'Json': self.sud.download()}) elif 'Upload' in pair[0]: