Wire Pause/Stop to the Sud state machine, with a resumable pause
components/sud.py: adds SudState.PAUSED. pause() freezes RAMPING/ HOLDING (the hold countdown and ramp-reached checks both no-op while PAUSED, since they're gated on the exact state they froze); start() now doubles as resume when paused. stop() aborts back to IDLE from any in-progress state, reusing the same reset logic as __init__/ upload() (factored into _reset_run_state()). tasks/sud.py: maps 'Pause'/'Stop' messages to the new methods, and turns the stirrer off on IDLE (not just DONE) so stop() actually silences it instead of leaving the last step's stirring running. client/brewpi_gui.py: wires the Pause/Stop toolbar actions, extends update_sud_actions() so Start doubles as Resume and Stop stays usable while paused, and freezes the forecast plot's progress line for the duration of a pause (tracked via accumulated paused time) instead of letting it drift ahead of actual progress.
This commit is contained in:
+37
-7
@@ -19,6 +19,7 @@ import asyncio
|
|||||||
# 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" for enabling/disabling the Start/Pause/Stop toolbar actions.
|
# "running" for enabling/disabling the Start/Pause/Stop toolbar actions.
|
||||||
SUD_RUNNING_STATES = {"SudState.RAMPING", "SudState.HOLDING", "SudState.WAIT_USER"}
|
SUD_RUNNING_STATES = {"SudState.RAMPING", "SudState.HOLDING", "SudState.WAIT_USER"}
|
||||||
|
SUD_PAUSED_STATE = "SudState.PAUSED"
|
||||||
|
|
||||||
|
|
||||||
def _style_axis(ax):
|
def _style_axis(ax):
|
||||||
@@ -182,8 +183,12 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.sud_state = None
|
self.sud_state = None
|
||||||
# time.monotonic() of the last IDLE/DONE -> running transition, used
|
# time.monotonic() of the last IDLE/DONE -> running transition, used
|
||||||
# to position the forecast plot's progress line; None while not
|
# 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_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_pot = self.msg_dispatch.msgio_get('Pot')
|
||||||
self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
|
self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
|
||||||
self.msg_heater = self.msg_dispatch.msgio_get('Heater')
|
self.msg_heater = self.msg_dispatch.msgio_get('Heater')
|
||||||
@@ -202,6 +207,8 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.update_sud_actions()
|
self.update_sud_actions()
|
||||||
self.btn_connect.clicked.connect(self.on_btn_connect_clicked)
|
self.btn_connect.clicked.connect(self.on_btn_connect_clicked)
|
||||||
self.actionStart.triggered.connect(self.on_action_sud_start)
|
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.actionSudDownload.triggered.connect(self.on_action_sud_download)
|
||||||
self.actionSudUpload.triggered.connect(self.on_action_sud_upload)
|
self.actionSudUpload.triggered.connect(self.on_action_sud_upload)
|
||||||
self.Slider_pwr_soll.valueChanged.connect(self.on_slider_pwr_soll_changed)
|
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)
|
self.plot_power_set, self.plot_power_eff)
|
||||||
|
|
||||||
if self.sud_start_time is not None:
|
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:
|
else:
|
||||||
self.forecast_plot.clear_progress()
|
self.forecast_plot.clear_progress()
|
||||||
|
|
||||||
@@ -294,13 +305,18 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.sud_loaded = False
|
self.sud_loaded = False
|
||||||
self.sud_state = None
|
self.sud_state = None
|
||||||
self.sud_start_time = None
|
self.sud_start_time = None
|
||||||
|
self.sud_paused_total = 0.0
|
||||||
|
self.sud_pause_started_at = None
|
||||||
self.update_sud_actions()
|
self.update_sud_actions()
|
||||||
|
|
||||||
def update_sud_actions(self):
|
def update_sud_actions(self):
|
||||||
running = self.sud_loaded and self.sud_state in SUD_RUNNING_STATES
|
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.actionStart.setEnabled(self.sud_loaded and not running)
|
||||||
self.actionPause.setEnabled(running)
|
self.actionPause.setEnabled(running)
|
||||||
self.actionStop.setEnabled(running)
|
self.actionStop.setEnabled(running or paused)
|
||||||
|
|
||||||
def on_slider_temp_soll_changed(self, value):
|
def on_slider_temp_soll_changed(self, value):
|
||||||
print("on_slider_temp_soll_changed {}".format(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):
|
def on_action_sud_start(self):
|
||||||
self.msg_sud.send({'Start': True})
|
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):
|
def on_action_sud_download(self):
|
||||||
# Pick the destination up front, on the GUI thread - the actual
|
# Pick the destination up front, on the GUI thread - the actual
|
||||||
# write happens in on_sud_changed(), which runs off a worker thread
|
# 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:
|
elif "Name" in key:
|
||||||
self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
|
self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
|
||||||
elif "State" in key:
|
elif "State" in key:
|
||||||
running_before = self.sud_state in SUD_RUNNING_STATES
|
prev_state = self.sud_state
|
||||||
self.sud_state = msg['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()
|
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_start_time = None
|
||||||
|
self.sud_paused_total = 0.0
|
||||||
|
self.sud_pause_started_at = None
|
||||||
elif "Step" in key:
|
elif "Step" in key:
|
||||||
step = msg['Step']
|
step = msg['Step']
|
||||||
descr = step.get('Descr')
|
descr = step.get('Descr')
|
||||||
|
|||||||
+32
-10
@@ -18,6 +18,7 @@ class SudState(Enum):
|
|||||||
HOLDING = 2
|
HOLDING = 2
|
||||||
WAIT_USER = 3
|
WAIT_USER = 3
|
||||||
DONE = 4
|
DONE = 4
|
||||||
|
PAUSED = 5
|
||||||
|
|
||||||
|
|
||||||
def _merge_defaults(default, override):
|
def _merge_defaults(default, override):
|
||||||
@@ -55,11 +56,8 @@ class Sud(AttributeChange):
|
|||||||
(self.name, self.description, self.schedule,
|
(self.name, self.description, self.schedule,
|
||||||
self.pot_mass, self.pot_material) = self._parse_data(data)
|
self.pot_mass, self.pot_material) = self._parse_data(data)
|
||||||
|
|
||||||
self.index = -1
|
self._paused_from = None
|
||||||
self.hold_remaining = 0.0
|
self._reset_run_state()
|
||||||
self.state = SudState.IDLE
|
|
||||||
self.step = None
|
|
||||||
self.user_message = None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_data(data):
|
def _parse_data(data):
|
||||||
@@ -78,6 +76,16 @@ class Sud(AttributeChange):
|
|||||||
|
|
||||||
return name, description, schedule, pot_mass, pot_material
|
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):
|
def download(self):
|
||||||
"""Returns the sud.json document currently on disk, for a client to
|
"""Returns the sud.json document currently on disk, for a client to
|
||||||
edit and hand back to upload()."""
|
edit and hand back to upload()."""
|
||||||
@@ -100,11 +108,7 @@ class Sud(AttributeChange):
|
|||||||
with open(self.path, 'w') as f:
|
with open(self.path, 'w') as f:
|
||||||
json.dump(data, f, indent='\t')
|
json.dump(data, f, indent='\t')
|
||||||
|
|
||||||
self.index = -1
|
self._reset_run_state()
|
||||||
self.hold_remaining = 0.0
|
|
||||||
self.state = SudState.IDLE
|
|
||||||
self.step = None
|
|
||||||
self.user_message = None
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def derive_plant_params(self, grain_mass, water_mass):
|
def derive_plant_params(self, grain_mass, water_mass):
|
||||||
@@ -125,11 +129,29 @@ class Sud(AttributeChange):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def start(self):
|
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:
|
if self.state != SudState.IDLE:
|
||||||
return
|
return
|
||||||
self.index = -1
|
self.index = -1
|
||||||
self._advance()
|
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):
|
def confirm(self):
|
||||||
if self.state == SudState.WAIT_USER:
|
if self.state == SudState.WAIT_USER:
|
||||||
self._advance()
|
self._advance()
|
||||||
|
|||||||
+5
-1
@@ -53,7 +53,7 @@ class SudTask(ATask):
|
|||||||
def on_state_changed(self, value):
|
def on_state_changed(self, value):
|
||||||
asyncio.create_task(self.send({'State': str(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_duty_cycle(1.0)
|
||||||
self.stirrer.set_speed(0)
|
self.stirrer.set_speed(0)
|
||||||
|
|
||||||
@@ -69,6 +69,10 @@ class SudTask(ATask):
|
|||||||
self.sud.start()
|
self.sud.start()
|
||||||
elif 'Confirm' in pair[0]:
|
elif 'Confirm' in pair[0]:
|
||||||
self.sud.confirm()
|
self.sud.confirm()
|
||||||
|
elif 'Pause' in pair[0]:
|
||||||
|
self.sud.pause()
|
||||||
|
elif 'Stop' in pair[0]:
|
||||||
|
self.sud.stop()
|
||||||
elif 'Download' in pair[0]:
|
elif 'Download' in pair[0]:
|
||||||
await self.send({'Json': self.sud.download()})
|
await self.send({'Json': self.sud.download()})
|
||||||
elif 'Upload' in pair[0]:
|
elif 'Upload' in pair[0]:
|
||||||
|
|||||||
Reference in New Issue
Block a user