#!/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_HOLDING_STATE = "SudState.HOLDING" 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): """A preview of a Sud schedule's temperature course, computed from its nominal ramp rates/hold durations alone (no plant/controller model - just how long the declared schedule says each step should take), so it can be shown as soon as a schedule is loaded/edited, before a brew is actually started (show_schedule()). Once running, nonideal ramp rates/user pauses make that static estimate drift from reality - show_dynamic() instead re-anchors the projection every tick: the already-elapsed part is the actual measured trace (line, solid), and only the remaining steps are projected forward from the live current temperature/step/hold_remaining (line_projected, dashed).""" 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() @staticmethod def _estimate_course(schedule, start_theta): """Walks the schedule, accumulating (t, theta) waypoints: ramps take abs(delta)/rate minutes at the declared rate, holds last for their duration at the temperature the preceding ramp reached. A step may carry both - ramp then hold - contributing both segments in turn.""" t = [0.0] theta = [start_theta] for step in schedule: ramp = step.get('ramp') hold = step.get('hold') target = step.get('temperature') if ramp is not None and target is not None: rate = ramp.get('rate', 0) if rate > 0: t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0) theta.append(target) if hold is not None: t.append(t[-1] + hold.get('duration', 0) * 60.0) theta.append(theta[-1]) return t, theta def show_schedule(self, schedule, start_theta, name): """Quick, approximate preview shown immediately when a schedule is loaded - assumes every ramp instantly achieves/sustains its declared rate, which real plants/controllers don't. Superseded by show_precomputed_course() shortly after, once the server's simulation-based forecast (components/sud_forecast.py) arrives.""" t, theta = self._estimate_course(schedule, start_theta) t_min = [seconds / 60.0 for seconds in t] self._show_static_course(t_min, theta, name) def show_precomputed_course(self, t_min, theta, name): """Like show_schedule(), but (t_min, theta) were already computed by the server simulating the schedule with its real plant/ controller - see Window.on_sud_forecast_received().""" self._show_static_course(t_min, theta, name) def _show_static_course(self, t_min, theta, name): self.line.set_data(t_min, theta) self.line_projected.set_data([], []) self.ax.relim() self.ax.autoscale_view() self.ax.set_title("{} - {:.0f} min total".format(name, t_min[-1] if t_min else 0.0), fontsize='small') self.figure.tight_layout() self.draw_idle() def show_dynamic(self, history, t_rem_min, theta_rem, name): """Re-anchored forecast for a run in progress: history is the [(t_min, theta), ...] actually measured so far (drawn solid); t_rem_min/theta_rem is the projected remainder (dashed), already computed by the caller - either the server's simulation-based SudForecastEstimator (preferred, see Window.on_plot_timer()) or, until that arrives, a quick naive abs(delta)/rate fallback.""" hist_t = [t for t, _ in history] hist_theta = [theta for _, theta in history] self.line.set_data(hist_t, hist_theta) self.line_projected.set_data(t_rem_min, theta_rem) self.ax.relim() self.ax.autoscale_view() total_min = t_rem_min[-1] if t_rem_min else (hist_t[-1] if hist_t else 0.0) self.ax.set_title("{} - {:.0f} min total (est.)".format(name, total_min), fontsize='small') self.figure.tight_layout() self.draw_idle() def show_no_schedule(self): 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_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) 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 = self.on_ws_connect_changed 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 # 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 # 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 # Resolved schedule + live progress within it, kept around so the # forecast plot can be dynamically re-anchored every tick instead of # only estimated once when the schedule was (re)loaded - see # refresh_forecast()/SudForecastPlot.show_dynamic(). 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 "already happened" part of the dynamic forecast. self.forecast_history = [] # (anchor_min, T, Theta) from the server's simulation-based estimate # of the remaining steps, recomputed on every step change - # anchor_min is elapsed_min as of receipt, so on_plot_timer() can # re-anchor the projection without it drifting right every tick. # None until it arrives (or after a step change, until the next one # does), in which case on_plot_timer() falls back to the naive # estimate. self.server_remaining_forecast = 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') 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') self.msg_pot.set_recv_handler(self.on_pot_changed) self.msg_sensor.set_recv_handler(self.on_sensor_changed) self.msg_heater.set_recv_handler(self.on_heater_changed) self.msg_stirrer.set_recv_handler(self.on_stirrer_changed) self.msg_tempctrl.set_recv_handler(self.on_tempctrl_changed) self.msg_sud.set_recv_handler(self.on_sud_changed) self.msg_system.set_recv_handler(self.on_system_changed) 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 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. While paused, uses the moment the pause began as "now" so it freezes instead of drifting ahead of actual progress.""" if self.sud_start_time is None: return None now = self.sud_pause_started_at if self.sud_pause_started_at is not None else time.monotonic() elapsed_real_s = now - self.sud_start_time - self.sud_paused_total # The forecast's x-axis is simulated-schedule time; scale real # elapsed time by the server's warp factor to match it. return elapsed_real_s * self.warp_factor / 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) # Re-anchor the forecast from the live state instead of redrawing # the static, increasingly-stale estimate from t=0. 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 projection 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)) if self.server_remaining_forecast is not None: # Anchored to elapsed_min as of when the server computed # this forecast, not the current tick's - it's only # recomputed on step changes, so re-adding the live, # ever-growing elapsed_min here would push the whole # projection further right every tick until the next # step change, instead of holding it fixed. anchor_min, t_rem, theta_rem = self.server_remaining_forecast else: # Server's simulation-based remainder hasn't arrived # yet (it's recomputed on every step change, off the # event loop) - fall back to the quick naive estimate # for this one tick. anchor_min = elapsed_min t_rem, theta_rem = self.forecast_plot._estimate_course(self._remaining_schedule(), self.plot_temp_ist) t_rem_min = [anchor_min + seconds / 60.0 for seconds in t_rem] self.forecast_plot.show_dynamic(self.forecast_history, t_rem_min, theta_rem, self.sud_name) 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 _remaining_schedule(self): """The not-yet-done part of sud_schedule, for SudForecastPlot's dynamic re-anchoring: the current step's already-finished phase is dropped, and its still-to-run phase is left for _estimate_course() to size from the live theta_now/hold_remaining it's seeded with, rather than the step's nominal start.""" schedule = self.sud_schedule index = self.sud_step_index if not schedule or index is None or not (0 <= index < len(schedule)): return [] current = schedule[index] rest = schedule[index + 1:] if self.sud_state == SUD_WAIT_USER_STATE: # The current step (both its ramp and hold, if any) is already # done - only waiting on the user to advance. return rest if self.sud_state == SUD_HOLDING_STATE: remaining_minutes = max(self.sud_hold_remaining, 0.0) / 60.0 return [{'hold': {'duration': remaining_minutes}}] + rest # RAMPING: _estimate_course() recomputes the ramp's remaining # distance from whatever start_theta it's given, so the step is # used as-is; its hold phase (if any) hasn't started yet either. return [current] + rest 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_start_time = None self.sud_paused_total = 0.0 self.sud_pause_started_at = 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.server_remaining_forecast = None 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 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_new(self): # Replaces the running Sud's schedule with an empty one, same as # Load but with a built-in empty document instead of a file dialog. 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 == 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() # 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_start_time is not None self.sud_start_time = None self.sud_paused_total = 0.0 self.sud_pause_started_at = None # on_plot_timer() only redraws the dynamic forecast while # sud_start_time 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 starting from the current temperature is # exactly right. start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0 self.forecast_plot.show_schedule(self.sud_schedule, start_theta, self.sud_name) 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'] self.sud_step_index = step.get('Index') self.sud_step_descr = step.get('Descr') self.sud_step_type = step.get('Type') # Stale for the step that just ended - the server is # already recomputing it (see on_plot_timer()'s fallback # to the naive estimate in the meantime). self.server_remaining_forecast = None self.update_status_step_label() elif "HoldRemaining" in key: self.sud_hold_remaining = msg['HoldRemaining'] self.update_status_step_label() elif "RemainingForecast" in key: forecast = msg['RemainingForecast'] # Anchor to elapsed_min as of receipt - close enough to # when the server actually computed it (it's seeded from # the live theta_ist at that moment) - rather than the # live elapsed_min on_plot_timer() sees on later ticks. anchor_min = self._elapsed_min() if anchor_min is not None: self.server_remaining_forecast = (anchor_min, forecast['T'], forecast['Theta']) elif "Forecast" in key: self.on_sud_forecast_received(msg['Forecast']) self.update_sud_actions() def on_sud_forecast_received(self, forecast): # The server computes this asynchronously (it simulates the whole # schedule) and it can arrive slightly after the schedule itself - # only apply it if we're still looking at the static "not running # yet" preview show_schedule() drew; a run already in progress (or # one that's since finished) owns the plot via show_dynamic()/its # frozen last frame instead. if self.sud_empty or self.sud_start_time is not None: return t_min = [seconds / 60.0 for seconds in forecast['T']] self.forecast_plot.show_precomputed_course(t_min, forecast['Theta'], self.sud_name) 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 # No measured starting temperature is available before a brew has # started; fall back to a plausible ambient guess. 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. start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0 self.forecast_plot.show_schedule(schedule, start_theta, 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.")