#!/usr/bin/env python3 from main_window import Ui_MainWindow from PyQt5 import QtWidgets, QtGui, QtCore import json import sys import threading import time from pathlib import Path import matplotlib matplotlib.use("Qt5Agg") from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure from matplotlib.ticker import AutoMinorLocator from components.sud import Sud from ws.client.ws_client import WsClient from ws.message import MessageDispatcherSync from queue import Queue import asyncio from user_config import UserConfig # 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" def _style_axis(ax): ax.set_facecolor('white') ax.spines['top'].set_visible(False) ax.spines['right'].set_visible(False) ax.xaxis.set_minor_locator(AutoMinorLocator()) ax.yaxis.set_minor_locator(AutoMinorLocator()) ax.tick_params(which='major', direction='out', labelsize='small') ax.tick_params(which='minor', direction='out', length=2) ax.grid(which='major', linestyle='-', linewidth=0.5, alpha=0.3) ax.grid(which='minor', linestyle=':', linewidth=0.5, alpha=0.15) class RealtimePlot(FigureCanvasQTAgg): """Live theta/heatrate/heater-power strip charts, mirroring figure 1 of scripts/demos/sud/demo_sud.py but fed from the websocket connection instead of an offline simulation run.""" def __init__(self): figure = Figure(facecolor='white') FigureCanvasQTAgg.__init__(self, figure) self.ax_temp, self.ax_rate, self.ax_power = figure.subplots(3, 1, sharex=True) self.line_temp_ist, = self.ax_temp.plot([], [], '-b', linewidth=1, label="theta_ist") self.line_temp_soll, = self.ax_temp.plot([], [], '-r', linewidth=1, label="theta_soll") self.ax_temp.set_ylabel("Temp [°C]") self.ax_temp.legend(loc='upper left', fontsize='small') self.line_rate_ist, = self.ax_rate.plot([], [], '-b', linewidth=1, label="heatrate_ist") self.line_rate_soll, = self.ax_rate.plot([], [], '-r', linewidth=1, label="heatrate_soll") self.ax_rate.set_ylabel("Rate [K/min]") self.ax_rate.legend(loc='upper left', fontsize='small') self.line_power_set, = self.ax_power.plot([], [], '-r', linewidth=1, label="power_set") self.line_power_eff, = self.ax_power.plot([], [], '-b', linewidth=1, label="power_eff") self.ax_power.set_ylabel("Power [W]") self.ax_power.set_xlabel("t [s]") self.ax_power.legend(loc='upper left', fontsize='small') for ax in (self.ax_temp, self.ax_rate, self.ax_power): _style_axis(ax) # Only the bottom subplot needs x tick labels - sharex keeps the # others in sync without repeating them. self.ax_temp.label_outer() self.ax_rate.label_outer() figure.tight_layout() self.reset() def reset(self): self.t0 = time.monotonic() self.t = [] self.temp_ist = [] self.temp_soll = [] self.rate_ist = [] self.rate_soll = [] self.power_set = [] self.power_eff = [] def sample(self, temp_ist, temp_soll, rate_ist, rate_soll, power_set, power_eff): self.t.append(time.monotonic() - self.t0) self.temp_ist.append(temp_ist) self.temp_soll.append(temp_soll) self.rate_ist.append(rate_ist) self.rate_soll.append(rate_soll) self.power_set.append(power_set) self.power_eff.append(power_eff) self.line_temp_ist.set_data(self.t, self.temp_ist) self.line_temp_soll.set_data(self.t, self.temp_soll) self.line_rate_ist.set_data(self.t, self.rate_ist) self.line_rate_soll.set_data(self.t, self.rate_soll) self.line_power_set.set_data(self.t, self.power_set) self.line_power_eff.set_data(self.t, self.power_eff) for ax in (self.ax_temp, self.ax_rate, self.ax_power): ax.relim() ax.autoscale_view() self.draw_idle() class SudForecastPlot(FigureCanvasQTAgg): """Compares a Sud schedule's actual control behavior (line, solid) against a fixed, simulation-based forecast (line_projected, dashed) - the same plant/controller model the real run uses (components/sud_forecast.py's SudForecastEstimator - see show_computing()/show_forecast()). The forecast is computed once per piecewise segment and never revisited once a run is using it - the whole point is an honest, unmoving baseline to compare reality against, not a prediction that keeps re-anchoring itself to match whatever's actually happening. The one exception is a step requiring user confirmation: a human's response time genuinely can't be forecast, so the segment just stops there, and the next one is computed for real (anchored at the real elapsed time and temperature) only once the user actually confirms - see tasks/sud.py's SudTask._continue_forecast_after_confirm(). extend_forecast_while_waiting() keeps the dashed line visually flat through the wait in the meantime.""" def __init__(self): figure = Figure(facecolor='white') FigureCanvasQTAgg.__init__(self, figure) self.ax = figure.subplots(1, 1) self.line, = self.ax.plot([], [], '-b', linewidth=1.5, zorder=2) self.line_projected, = self.ax.plot([], [], '--b', linewidth=1.5, alpha=0.5, zorder=2) self.progress_line = self.ax.axvline(0, color='g', linewidth=1.5, visible=False, zorder=2) # Current temperature - drawn behind (lower zorder than) the # forecast/progress lines so it never covers them. self.current_temp_line = self.ax.axhline(0, color='r', linewidth=1.5, visible=False, zorder=1) self.ax.set_xlabel("t [min]") self.ax.set_ylabel("Temp [°C]") _style_axis(self.ax) figure.tight_layout() # The forecast as last received from the server (see # show_forecast()) - kept around so extend_forecast_while_waiting() # can repeat its last value without the caller needing to pass it # again every tick. self._forecast_t_min = [] self._forecast_theta = [] def show_computing(self, name): """Shown right after a schedule loads, while the first simulation-based forecast segment is still being computed server-side (see Window.on_sud_forecast_received()) - typically resolved within a second. Deliberately shows nothing rather than an approximate placeholder; leaves the axes' current view alone (there's nothing meaningful to fit yet), so whatever was on screen before just stays frozen underneath the title change until show_forecast() replaces it.""" self._forecast_t_min = [] self._forecast_theta = [] self.line.set_data([], []) self.line_projected.set_data([], []) self.ax.set_title("{} - computing forecast...".format(name), fontsize='small') self.figure.tight_layout() self.draw_idle() def show_forecast(self, t_min, theta, name, finished): """The forecast curve (dashed) - computed once, piecewise, by the server simulating the schedule with its real plant/controller (components/sud_forecast.py) - see Window.on_sud_forecast_ received(). Called again, with a longer t_min/theta, each time a further piecewise segment gets appended after a real user confirmation; finished is False while the forecast doesn't yet cover the rest of the schedule (stopped at a step requiring confirmation - see extend_forecast_while_waiting()). Locks the axes to fit the forecast alone, regardless of whatever the actual trace (line) is currently doing.""" self._forecast_t_min = t_min self._forecast_theta = theta self.line_projected.set_data(t_min, theta) self._set_fixed_limits(t_min, theta) total = t_min[-1] if t_min else 0.0 suffix = "" if finished else " so far" self.ax.set_title("{} - {:.0f} min{} total".format(name, total, suffix), fontsize='small') self.figure.tight_layout() self.draw_idle() def extend_forecast_while_waiting(self, elapsed_min): """Called every tick while the real Sud is waiting on user confirmation: a human's response time can't be forecast, so rather than guess, the dashed line just repeats its last value out to 'now' until the next segment is appended for real (see tasks/sud.py's SudTask._continue_forecast_after_confirm(), fired the moment the real confirmation happens) - at which point show_forecast() overwrites this temporary extension with the actual next segment.""" if not self._forecast_t_min or elapsed_min <= self._forecast_t_min[-1]: return last_theta = self._forecast_theta[-1] self.line_projected.set_data(self._forecast_t_min + [elapsed_min], self._forecast_theta + [last_theta]) self.draw_idle() def _set_fixed_limits(self, t_list, theta_list): """Explicitly sets (and, unlike relim()+autoscale_view(), locks) the axes' view to fit exactly the given data, with matplotlib's usual ~5% margin. Locking matters here: relim()+autoscale_view() leaves autoscaling switched on, so a later, unrelated redraw - e.g. set_current_temp()'s continuous updates, which keep that indicator live regardless of whether a forecast is even showing - can silently re-autoscale and stretch the view back out to fit it instead, squashing the actual forecast curve down to nothing.""" if not t_list or not theta_list: return t_lo, t_hi = min(t_list), max(t_list) th_lo, th_hi = min(theta_list), max(theta_list) t_pad = (t_hi - t_lo) * 0.05 or 1.0 th_pad = (th_hi - th_lo) * 0.05 or 1.0 self.ax.set_xlim(t_lo - t_pad, t_hi + t_pad) self.ax.set_ylim(th_lo - th_pad, th_hi + th_pad) def show_dynamic(self, history): """Updates the actual measured trace (solid) only - the forecast (dashed) is left exactly as show_forecast() last drew it (that's the whole point: an honest, unmoving baseline - see this class's docstring), and the axes stay locked to whatever it set, so divergence between the two is visible rather than auto-hidden by the view rescaling to chase whichever line is currently drawing.""" hist_t = [t for t, _ in history] hist_theta = [theta for _, theta in history] self.line.set_data(hist_t, hist_theta) self.draw_idle() def show_no_schedule(self): self._forecast_t_min = [] self._forecast_theta = [] self.line.set_data([], []) self.line_projected.set_data([], []) self.clear_progress() self.ax.relim() self.ax.autoscale_view() self.ax.set_title("No sud loaded", fontsize='small') self.figure.tight_layout() self.draw_idle() def set_progress(self, t_min): self.progress_line.set_xdata([t_min, t_min]) self.progress_line.set_visible(True) self.draw_idle() def clear_progress(self): self.progress_line.set_visible(False) self.draw_idle() def set_current_temp(self, theta): self.current_temp_line.set_ydata([theta, theta]) self.current_temp_line.set_visible(True) self.draw_idle() def clear_current_temp(self): self.current_temp_line.set_visible(False) self.draw_idle() class Window(QtWidgets.QMainWindow, Ui_MainWindow): # on_*_changed()/on_ws_connect_changed() are invoked straight from the # websocket client's background thread (WsClient.bg_thread), but Qt # widgets may only be touched from the GUI thread. A signal/slot crosses # that boundary safely (Qt auto-queues the slot call onto the receiver's # thread when sender and receiver differ) - recv_signal carries the # handler itself plus its one argument, so every channel can share it # instead of needing one signal per handler. recv_signal = QtCore.pyqtSignal(object, object) def __init__(self): QtWidgets.QMainWindow.__init__(self) self.setWindowIcon(QtGui.QIcon('res/beer.png')) self.recv_signal.connect(self._dispatch_recv) self.user_config = UserConfig() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self.msg_dispatch = MessageDispatcherSync(auto_subscribe=True) self.msg_dispatch.on_connect_changed = lambda connected: self.recv_signal.emit(self.on_ws_connect_changed, connected) self.ws_client = WsClient(listener=self.msg_dispatch, loop=loop) self.connected = False self.sud_loaded = False # Whether the loaded schedule has zero steps (e.g. just after "New # Sud") - distinct from sud_loaded, which just means a Sud is # configured server-side at all. Defaults True so Start/Pause/Stop # stay disabled until a non-empty schedule is actually confirmed. self.sud_empty = True self.sud_state = None self.sud_user_message = None # Mirrors the server's TempController.enabled - gates the # temp/heatrate setpoint controls (see on_tempctrl_changed()). self.tc_enabled = 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. self.ambient_temp = None self.warp_factor = 1.0 # Elapsed simulated seconds of the current run, as reported by the # server (Sud's own tick-counted 'elapsed' - see components/sud.py) - # None while not running. Used to position the forecast plot's # progress line; ground truth, not reconstructed from wall-clock # time, since the server's actual achieved speedup runs measurably # below its nominal configured warp factor under real scheduling # load, which used to make the live trace visibly drift from the # forecast. self.sud_elapsed_seconds = None self.sud_name = '' self.sud_schedule = [] self.sud_step_index = None self.sud_step_descr = None self.sud_step_type = None self.sud_hold_remaining = 0.0 # [(t_min, theta), ...] actually measured since the current run # started - the "actual" half of the forecast-vs-actual # comparison (SudForecastPlot.show_dynamic()). self.forecast_history = [] 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') self.msg_stirrer = self.msg_dispatch.msgio_get('Stirrer') self.msg_tempctrl = self.msg_dispatch.msgio_get('TempCtrl') self.msg_sud = self.msg_dispatch.msgio_get('Sud') self.msg_system = self.msg_dispatch.msgio_get('System') # set_recv_handler's callback runs on WsClient's background thread - # route each through recv_signal instead of wiring the real handler # directly, so its body always runs on the GUI thread. self.msg_pot.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_pot_changed, msg)) self.msg_sensor.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sensor_changed, msg)) self.msg_heater.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_heater_changed, msg)) self.msg_stirrer.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_stirrer_changed, msg)) self.msg_tempctrl.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_tempctrl_changed, msg)) self.msg_sud.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sud_changed, msg)) self.msg_system.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_system_changed, msg)) self.setupUi(self) # Matches tc_enabled's default (False) until the server says otherwise. self.Slider_temp_soll.setEnabled(False) self.doubleSpinBox_heatrate_soll.setEnabled(False) # Permanent (right-aligned, not cleared by showMessage()/clearMessage()) # label for static environment info - distinct from the status bar's # transient step/schedule messages. self.status_label_env = QtWidgets.QLabel() self.statusBar().addPermanentWidget(self.status_label_env) self.update_status_env_label() # Blinking indicator for the temperature controller's own COOL # state (target below the current temperature - the heater is # forced off server-side and we just wait for ambient loss to # catch up). self.tc_cooling = False self.status_label_cooling = QtWidgets.QLabel("Cool down") self.status_label_cooling.setStyleSheet("color: blue; font-weight: bold;") self.status_label_cooling.setVisible(False) self.statusBar().addPermanentWidget(self.status_label_cooling) self.cooling_blink_timer = QtCore.QTimer(self) self.cooling_blink_timer.timeout.connect(self.on_cooling_blink) 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.actionSudNew.triggered.connect(self.on_action_sud_new) self.actionSudSave.triggered.connect(self.on_action_sud_save) self.actionSudLoad.triggered.connect(self.on_action_sud_load) self.Slider_pwr_soll.valueChanged.connect(self.on_slider_pwr_soll_changed) self.Slider_temp_soll.valueChanged.connect(self.on_slider_temp_soll_changed) self.Slider_speed_soll.valueChanged.connect(self.on_slider_stirrer_speed_soll_changed) self.doubleSpinBox_heatrate_soll.valueChanged.connect(self.on_heatrate_soll_changed) self.checkbox_heater_activate.stateChanged.connect(self.on_checkbox_changed) self.checkbox_stirrer_activate.stateChanged.connect(self.on_checkbox_stirrer_activate_changed) self.checkbox_tc_enable.stateChanged.connect(self.on_checkbox_tc_enable_changed) self.lineEdit_ambient.returnPressed.connect(self.on_ambient_entry_changed) self.btn_pot_reset.clicked.connect(self.on_action_pot_reset) # Only meaningful (and only shown) once the server confirms its Pot # is simulated - see on_system_changed()'s "PlantSim" handling. self.btn_pot_reset.setVisible(False) self.send_data = Queue() self.slider_pwr_initial_update = True self.slider_temp_soll_initial_update = True self.slider_speed_initial_update = True self.checkbox_heater_activate_initial_update = True self.checkbox_stirrer_activate_initial_update = True self.heatrate_soll_initial_update = True self.sud_save_path = None # Latest values sampled by the plot timer - written from the # websocket recv thread, read from the GUI thread; plain floats so # no locking, consistent with how the rest of the GUI is wired. self.plot_temp_ist = 0.0 self.plot_temp_soll = 0.0 self.plot_rate_ist = 0.0 self.plot_rate_soll = 0.0 self.plot_power_set = 0.0 self.plot_power_eff = 0.0 self.plot = RealtimePlot() plot_layout = QtWidgets.QVBoxLayout(self.widget_plot) plot_layout.setContentsMargins(0, 0, 0, 0) plot_layout.addWidget(self.plot) self.plot_timer = QtCore.QTimer(self) self.plot_timer.timeout.connect(self.on_plot_timer) self.plot_timer.start(1000) self.forecast_plot = SudForecastPlot() forecast_layout = QtWidgets.QVBoxLayout(self.widget_plot_forecast) forecast_layout.setContentsMargins(0, 0, 0, 0) forecast_layout.addWidget(self.forecast_plot) def _dispatch_recv(self, handler, msg): """recv_signal's slot - runs on the GUI thread regardless of which thread emitted the signal, so handler(msg) can touch Qt widgets safely.""" handler(msg) def connect(self): self.slider_pwr_initial_update = True self.slider_temp_soll_initial_update = True self.slider_speed_initial_update = True self.checkbox_heater_activate_initial_update = True self.checkbox_stirrer_activate_initial_update = True self.heatrate_soll_initial_update = True self.plot.reset() self.ws_client.connect(uri=self.plainTextUri.toPlainText()) # Fetch the schedule for the Automatic tab's forecast preview - not # a user-initiated save, so sud_save_path stays None and # on_sud_changed() routes the reply to update_sud_forecast() instead # of a save dialog. self.msg_sud.send({'Save': True}) # Re-apply the last ambient temperature we set, in case the server # restarted since (and so fell back to its own config.json default). remembered_ambient = self.user_config.get('ambient_temperature') if remembered_ambient is not None: self.msg_system.send({'AmbientTemp': remembered_ambient}) def _elapsed_min(self): """Simulated-schedule minutes elapsed since the run started, or None if no run is in progress - straight from the server's own tick-counted Sud.elapsed (see on_sud_changed()'s 'Elapsed' handling), already frozen while PAUSED there.""" if self.sud_elapsed_seconds is None: return None return self.sud_elapsed_seconds / 60.0 def on_plot_timer(self): self.plot.sample( self.plot_temp_ist, self.plot_temp_soll, self.plot_rate_ist, self.plot_rate_soll, self.plot_power_set, self.plot_power_eff) elapsed_min = self._elapsed_min() if elapsed_min is not None: self.forecast_plot.set_progress(elapsed_min) # Frozen while PAUSED (elapsed_min isn't advancing either, and # the paused phase - ramp or hold - isn't visible to the client # to resume the trace from correctly) - just leave the last # draw up. if self.sud_state != SUD_PAUSED_STATE and self.plot_temp_ist: self.forecast_history.append((elapsed_min, self.plot_temp_ist)) self.forecast_plot.show_dynamic(self.forecast_history) if self.sud_state == SUD_WAIT_USER_STATE: # A human's confirm time can't be forecast - the dashed # line just repeats its last value out to 'now' until # the real confirmation appends the next segment (see # tasks/sud.py's SudTask._continue_forecast_after_ # confirm()). self.forecast_plot.extend_forecast_while_waiting(elapsed_min) else: self.forecast_plot.clear_progress() if self.plot_temp_ist: self.forecast_plot.set_current_temp(self.plot_temp_ist) else: self.forecast_plot.clear_current_temp() def disconnect(self): # ws_client.disconnect() blocks until the websocket's close handshake # completes (or times out, ~10s if the server doesn't ack promptly), # joining the connection's background thread - run it off a throwaway # thread so that wait doesn't freeze the GUI. on_ws_connect_changed() # still flips the button once the disconnect actually finishes. threading.Thread(target=self.ws_client.disconnect, daemon=True).start() def on_btn_connect_clicked(self): if self.connected: self.disconnect() else: self.connect() def on_ws_connect_changed(self, connected): self.connected = connected self.btn_connect.setText("Disconnect" if connected else "Connect") if not connected: # Nothing reported over a dead connection can be trusted anymore. self.sud_loaded = False self.sud_empty = True self.sud_state = None self.sud_user_message = None self.tc_enabled = False self.checkbox_tc_enable.blockSignals(True) self.checkbox_tc_enable.setChecked(False) self.checkbox_tc_enable.blockSignals(False) self.Slider_temp_soll.setEnabled(False) self.doubleSpinBox_heatrate_soll.setEnabled(False) self.ambient_temp = None self.lineEdit_ambient.clear() self.btn_pot_reset.setVisible(False) self.warp_factor = 1.0 self.sud_elapsed_seconds = None self.sud_name = '' self.sud_schedule = [] self.sud_step_index = None self.sud_step_descr = None self.sud_step_type = None self.sud_hold_remaining = 0.0 self.forecast_history = [] self.set_tc_cooling(False) self.update_sud_actions() self.update_status_env_label() self.setWindowTitle("BrewPi") def update_sud_actions(self): can_run = self.sud_loaded and not self.sud_empty running = can_run and self.sud_state in SUD_RUNNING_STATES paused = can_run 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(can_run and not running) self.actionPause.setEnabled(running) self.actionStop.setEnabled(running or paused) def on_system_changed(self, msg): print("on_system_changed {}".format(msg)) for key in msg: if "AmbientTemp" in key: self.ambient_temp = msg['AmbientTemp'] # Only sync the entry's text while the user isn't actively # editing it, so a server echo doesn't clobber in-progress # typing. if not self.lineEdit_ambient.hasFocus(): self.lineEdit_ambient.setText("{:.1f}".format(self.ambient_temp)) elif "WarpFactor" in key: self.warp_factor = msg['WarpFactor'] elif "PlantSim" in key: self.btn_pot_reset.setVisible(msg['PlantSim']) self.update_status_env_label() def on_action_pot_reset(self): self.msg_pot.send({'Reset': True}) def on_ambient_entry_changed(self): try: value = float(self.lineEdit_ambient.text()) except ValueError: return self.user_config.set('ambient_temperature', value) self.msg_system.send({'AmbientTemp': value}) def update_status_env_label(self): ambient = "{:.1f}°C".format(self.ambient_temp) if self.ambient_temp is not None else "-" self.status_label_env.setText("Ambient: {} Warp: {:.1f}x".format(ambient, self.warp_factor)) @staticmethod def _make_window_title(name, description): title = "BrewPi" if name: title += " - {}".format(name) if description: title += " - {}".format(description) return title def set_tc_cooling(self, cooling): if cooling == self.tc_cooling: return self.tc_cooling = cooling if cooling: self.status_label_cooling.setVisible(True) self.cooling_blink_timer.start(500) else: self.cooling_blink_timer.stop() self.status_label_cooling.setVisible(False) def on_cooling_blink(self): self.status_label_cooling.setVisible(not self.status_label_cooling.isVisible()) def on_slider_temp_soll_changed(self, value): print("on_slider_temp_soll_changed {}".format(value)) self.msg_tempctrl.send({'Soll': {'Temp': value}}) def on_heatrate_soll_changed(self, value): value = round(value, 1) print("on_textbox_heatrate_soll_changed {}".format(value)) self.msg_tempctrl.send({'Soll': {'Rate': value}}) def on_slider_pwr_soll_changed(self, value): print("on_slider_pwr_soll_changed {}".format(value)) self.msg_heater.send({'Power': value}) def on_checkbox_changed(self, value): print("on_checkbox_changed {}".format(value)) self.msg_heater.send({'Activate': int(value == 2)}) def on_slider_stirrer_speed_soll_changed(self, value): print("on_slider_stirrer_speed_soll_changed {}".format(value)) self.msg_stirrer.send({'Speed': value}) def on_checkbox_stirrer_activate_changed(self, value): print("on_checkbox_stirrer_activate_changed {}".format(value)) self.msg_stirrer.send({'Activate': int(value == 2)}) def on_checkbox_tc_enable_changed(self, value): print("on_checkbox_tc_enable_changed {}".format(value)) self.msg_tempctrl.send({'Enable': value == 2}) 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 show_user_message(self, message): # on_sud_changed() already runs on the GUI thread (via recv_signal), # so this can show the dialog directly. 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_new(self): # Sends an empty schedule via the same Load path Load Sud uses, with # a built-in empty document instead of a file dialog - the server # itself refuses this (like any Load) while a run is in progress, # surfacing an error (see on_sud_changed()'s 'Error' handling) # rather than the GUI trying to guess that here. self.msg_sud.send({'Load': {'Name': '', 'Description': '', 'pot_mass': 0, 'pot_material': None, 'steps': []}}) 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 # and must not touch Qt (e.g. open a file dialog) itself. start_dir = self.user_config.get('sud_dir', '') path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Sud", start_dir, filter="JSON files (*.json)") if not path: return self.user_config.set('sud_dir', str(Path(path).parent)) self.sud_save_path = path self.msg_sud.send({'Save': True}) def on_action_sud_load(self): start_dir = self.user_config.get('sud_dir', '') path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load Sud", start_dir, filter="JSON files (*.json)") if not path: return self.user_config.set('sud_dir', str(Path(path).parent)) with open(path) as f: data = json.load(f) self.msg_sud.send({'Load': data}) def on_pot_changed(self, msg): print("on_pot_changed {}".format(msg)) for key in msg: if "Power" in key: self.lcdNumber_power_pot.display(msg['Power']) def on_sensor_changed(self, msg): print("on_sensor_changed {}".format(msg)) def on_tempctrl_changed(self, msg): print("on_tempctrl_changed {}".format(msg)) for key in msg: if 'State' in key: self.label_state.setText(msg['State']) self.set_tc_cooling(msg['State'] == 'States.COOL') elif "Soll" in key: submsg = msg['Soll'] if 'Temp' in submsg: self.plot_temp_soll = submsg['Temp'] self.lcdNumber_temp_soll.display(str(submsg['Temp'])) if self.slider_temp_soll_initial_update: # blockSignals: setValue() here is just syncing the # slider's display to the server's current value, not # a user edit - without this it would also fire # valueChanged and echo the value straight back as a # command. self.Slider_temp_soll.blockSignals(True) self.Slider_temp_soll.setValue(int(round(submsg['Temp']))) self.Slider_temp_soll.blockSignals(False) self.slider_temp_soll_initial_update = False if 'Rate' in submsg: subsubmsg = submsg['Rate'] for subkey in subsubmsg: if "Current" in subkey: self.plot_rate_soll = subsubmsg['Current'] self.lcdNumber_heatrate_soll.display(str(subsubmsg['Current'])) if "Set" in subkey: if self.heatrate_soll_initial_update: self.heatrate_soll_initial_update = False self.doubleSpinBox_heatrate_soll.blockSignals(True) self.doubleSpinBox_heatrate_soll.setValue(subsubmsg['Set']) self.doubleSpinBox_heatrate_soll.blockSignals(False) elif "Enabled" in key: self.tc_enabled = msg['Enabled'] self.checkbox_tc_enable.blockSignals(True) self.checkbox_tc_enable.setChecked(self.tc_enabled) self.checkbox_tc_enable.blockSignals(False) self.Slider_temp_soll.setEnabled(self.tc_enabled) self.doubleSpinBox_heatrate_soll.setEnabled(self.tc_enabled) if "Ist" in key: submsg = msg['Ist'] if 'Temp' in submsg: self.plot_temp_ist = submsg['Temp'] self.lcdNumber_temp_ist.display(str(submsg['Temp'])) if 'Rate' in submsg: self.plot_rate_ist = submsg['Rate'] self.lcdNumber_heatrate_ist.display(str(submsg['Rate'])) def on_heater_changed(self, msg): print("on_heater_changed {}".format(msg)) for key in msg: if "Activate" in key: if self.checkbox_heater_activate_initial_update: self.checkbox_heater_activate.blockSignals(True) self.checkbox_heater_activate.setCheckState(2 if msg['Activate'] == 1 else 0) self.checkbox_heater_activate.blockSignals(False) self.checkbox_heater_activate_initial_update = False elif "PowerSet" in key: self.plot_power_set = msg['PowerSet'] elif "Power" in key: self.plot_power_eff = msg['Power'] self.lcdNumber_power_heater.display(msg['Power']) if self.slider_pwr_initial_update: # blockSignals: this is a display-only sync of the # slider to the heater's current power, not a manual # override - letting valueChanged fire here would echo # it back as {'Power': ...}, which tasks/heater.py's # power_soll = max(power_soll, power_actor) latches as a # floor that never goes back down on its own. That's the # "stale power > 0 after reconnect" bug. self.Slider_pwr_soll.blockSignals(True) self.Slider_pwr_soll.setValue(int(round(msg['Power']))) self.Slider_pwr_soll.blockSignals(False) self.slider_pwr_initial_update = False elif "Capabilities" in key: submsg = msg['Capabilities'] if "Power" in submsg: submsg = submsg['Power'] self.Slider_pwr_soll.setMinimum(int(submsg['Min'])) self.Slider_pwr_soll.setMaximum(int(submsg['Max'])) def on_stirrer_changed(self, msg): print("on_stirrer_changed {}".format(msg)) for key in msg: if "Activate" in key: if self.checkbox_stirrer_activate_initial_update: self.checkbox_stirrer_activate.blockSignals(True) self.checkbox_stirrer_activate.setCheckState(2 if msg['Activate'] == 1 else 0) self.checkbox_stirrer_activate.blockSignals(False) self.checkbox_stirrer_activate_initial_update = False elif "Speed" in key: if self.slider_speed_initial_update: self.Slider_speed_soll.blockSignals(True) self.Slider_speed_soll.setValue(int(round(msg['Speed']))) self.Slider_speed_soll.blockSignals(False) self.slider_speed_initial_update = False elif "Capabilities" in key: submsg = msg['Capabilities'] if "Power" in submsg: submsg = submsg['Power'] self.Slider_speed_soll.setMinimum(int(submsg['Min'])) self.Slider_speed_soll.setMaximum(int(submsg['Max'])) def on_sud_changed(self, msg): print("on_sud_changed {}".format(msg)) # 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: if "Json" in key: if self.sud_save_path: with open(self.sud_save_path, 'w') as f: json.dump(msg['Json'], f, indent='\t') self.sud_save_path = None else: self.update_sud_forecast(msg['Json']) elif "Name" in key: self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000) self.setWindowTitle(self._make_window_title(msg.get('Name', ''), msg.get('Description', ''))) elif "State" in key: prev_state = self.sud_state self.sud_state = msg['State'] if self.sud_state in SUD_RUNNING_STATES and self.sud_elapsed_seconds is None: self.sud_elapsed_seconds = 0.0 # Start the dynamic forecast's measured-history trace # fresh for this run. self.forecast_history = [] elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE: was_running = self.sud_elapsed_seconds is not None self.sud_elapsed_seconds = None # on_plot_timer() only redraws the dynamic forecast while # sud_elapsed_seconds is set. if was_running: # A run just finished or was stopped - leave the # dynamic view's last frame (the actual measured # history) up rather than recomputing a "static" # plan from here, which would walk the *whole* # original schedule again starting from wherever # the brew happens to have ended up (e.g. close to # the last step's target), producing a nonsensical # total. pass elif self.sud_schedule: # Fresh load (or reconnecting to an already-idle # server) - no run has happened yet, so the upfront # plan is exactly right; on_sud_forecast_received() # fills it in once the simulated forecast arrives # (a fresh Save request goes out on every connect, # so this resolves shortly either way). 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) elif "UserMessage" in key: self.sud_user_message = msg['UserMessage'] elif "Step" in key: step = msg['Step'] self.sud_step_index = step.get('Index') self.sud_step_descr = step.get('Descr') self.sud_step_type = step.get('Type') self.update_status_step_label() elif "HoldRemaining" in key: self.sud_hold_remaining = msg['HoldRemaining'] self.update_status_step_label() elif "Elapsed" in key: # Sud.elapsed resets to 0 on every fresh Load too (not just # on an actual run start - see components/sud.py's # _reset_run_state()) and broadcasts unconditionally - only # apply it once the State handling above has already put # this client into dynamic-view mode (sud_elapsed_seconds # not None), so a mere Load while idle can't masquerade as # a run starting. if self.sud_elapsed_seconds is not None: self.sud_elapsed_seconds = msg['Elapsed'] elif "Forecast" in key: self.on_sud_forecast_received(msg['Forecast']) elif "Error" in key: # The server clears this back to None right after sending # it (see tasks/sud.py's recv()) so it doesn't linger in # global_state and get replayed as stale to a client # connecting later - the clearing message itself must be # a no-op here, not an empty dialog. if msg['Error']: QtWidgets.QMessageBox.warning(self, "Sud", msg['Error']) self.update_sud_actions() def on_sud_forecast_received(self, forecast): # Computed asynchronously server-side and can arrive slightly # after the schedule itself - applies regardless of whether a # run is in progress, since this can also be a later piecewise # segment appended after a real user confirmation (see # tasks/sud.py's SudTask._continue_forecast_after_confirm()), not # just the initial one computed at Load. if self.sud_empty: return t_min = [seconds / 60.0 for seconds in forecast['T']] self.forecast_plot.show_forecast(t_min, forecast['Theta'], self.sud_name, forecast['Finished']) def update_status_step_label(self): if not self.sud_step_descr: self.statusBar().clearMessage() return text = "Step {}: {}".format(self.sud_step_index, self.sud_step_descr) if self.sud_step_type == 'hold': remaining = max(self.sud_hold_remaining, 0.0) text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60) self.statusBar().showMessage(text) def update_sud_forecast(self, doc): try: name, _, schedule, _, _ = Sud._parse_data(doc) except (KeyError, TypeError): return self.sud_name = name self.sud_schedule = schedule self.sud_empty = len(schedule) == 0 self.update_sud_actions() if self.sud_empty: self.forecast_plot.show_no_schedule() return # on_sud_forecast_received() fills this in once the simulated # forecast arrives. If a run is already in progress (e.g. # reconnecting mid-brew), the next on_plot_timer() tick switches # this over to the dynamic view instead. self.forecast_plot.show_computing(name) def closeEvent(self, event): print("Exitting gracefully!") self.ws_client.disconnect() if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) win = Window() win.show() sys.exit(app.exec_()) print ("End of program.")