#!/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') if ramp is not None: rate = ramp.get('rate', 0) target = step['temperature'] 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): t, theta = self._estimate_course(schedule, start_theta) t_min = [seconds / 60.0 for seconds in t] 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, remaining_schedule, theta_now, elapsed_min, name): """Re-anchored forecast for a run in progress: history is the [(t_min, theta), ...] actually measured so far (drawn solid), remaining_schedule the not-yet-done steps, projected forward (dashed) from (elapsed_min, theta_now) using the same nominal-rate estimate as show_schedule(), just re-seeded each call instead of computed once at t=0.""" hist_t = [t for t, _ in history] hist_theta = [theta for _, theta in history] self.line.set_data(hist_t, hist_theta) t_rem, theta_rem = self._estimate_course(remaining_schedule, theta_now) t_rem_min = [elapsed_min + seconds / 60.0 for seconds in t_rem] 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 elapsed_min 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 = [] 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 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) if self.sud_start_time is not None: # 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_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. elapsed_min = elapsed_real_s * self.warp_factor / 60.0 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)) remaining = self._remaining_schedule() self.forecast_plot.show_dynamic( self.forecast_history, remaining, self.plot_temp_ist, elapsed_min, 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.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: 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 - revert to the static view now, # rather than leaving the last dynamic draw frozen. if self.sud_schedule: 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') self.update_status_step_label() elif "HoldRemaining" in key: self.sud_hold_remaining = msg['HoldRemaining'] self.update_status_step_label() self.update_sud_actions() 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.")