#!/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" # Below this (simulated seconds), an 'Elapsed' push is treated as "this run # just started" rather than "reconnecting mid-brew" - see on_sud_changed()'s # Elapsed handling. Generous relative to typical dt (0.1-1s/tick): a fresh # start's first push always lands well under it, while reconnecting to an # established run (anything past its first handful of ticks) always lands # well over it - and seeding the history a few seconds "late" within a # genuinely fresh run is harmless anyway, since the temperature there is # still essentially the t=0 anchor. FRESH_RUN_ELAPSED_THRESHOLD_S = 5.0 def _format_duration(seconds): """mm:ss, or h:mm:ss past an hour - used by StepPlate's remaining-time label, where a brew step can run from a few minutes to a few hours.""" seconds = max(0, int(seconds)) hours, rem = divmod(seconds, 3600) minutes, secs = divmod(rem, 60) if hours: return "{}:{:02d}:{:02d}".format(hours, minutes, secs) return "{}:{:02d}".format(minutes, secs) 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 simulation-based forecast (line_projected, also solid but faded via alpha - see __init__()) - the same plant/controller model the real run uses (components/sud_forecast.py's SudForecastEstimator - see show_computing()/show_forecast()). The forecast covers the whole schedule from the very first Load, including through any step requiring user confirmation - a human's response time genuinely can't be forecast, so it's modeled as a zero-delay auto-confirm there instead of stopping. That assumption gets corrected once the schedule actually reaches the next real step boundary (anchored at the real elapsed time and temperature) - see tasks/sud.py's SudTask._reanchor_forecast() - at which point show_forecast() is called again with the corrected curve, replacing the optimistic guess outright rather than the GUI patching it up itself.""" 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() 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.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 (faded, via alpha) - computed 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 corrected t_min/theta, each time a real user confirmation replaces a zero-delay guess with one anchored at the real elapsed time and temperature; finished is False only in the pathological case of a step whose target can never be reached (components/sud_forecast.py's MAX_TICKS). Locks the axes to fit the forecast alone, regardless of whatever the actual trace (line) is currently doing.""" 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 _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 (also solid, but faded via alpha) 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.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 StepPlate(QtWidgets.QFrame): """One rectangle per schedule step, stacked top-to-bottom on the Progress tab to represent the whole Sud - see Window. _rebuild_step_plates()/_update_step_plates(). The LED at a glance communicates this step's status (not yet entered/finished/active); the row below it mirrors what's actually driving the brew at that step: target temp (resolved by the caller - a step without its own 'temperature' inherits whatever the previous one set, see components/sud.py's _build_step()), masses and ramp rate as configured in the schedule, plus, for whichever step is currently active, the same live actual-temp/stirrer readings the Manual tab shows (frozen at their last value once that step finishes, blank before it's ever been reached).""" LED_OFF = "#555555" LED_GREEN = "#2ecc71" LED_GREEN_DIM = "#1b5e36" LED_ORANGE = "#e67e22" LED_ORANGE_DIM = "#7a4111" def __init__(self, index, step, resolved_temp): QtWidgets.QFrame.__init__(self) self.setFrameShape(QtWidgets.QFrame.StyledPanel) self.setFrameShadow(QtWidgets.QFrame.Raised) # 'off' (not yet entered) | 'done' (finished, solid green) | # 'ramp'/'hold' (active, flashing orange/green - see apply_blink()). self.led_mode = 'off' self.led = QtWidgets.QLabel() self.led.setFixedSize(16, 16) self.label_title = QtWidgets.QLabel("Step {}: {}".format(index + 1, step.get('descr') or '')) self.label_title.setStyleSheet("font-weight: bold; color: black;") self.label_title.setWordWrap(True) self.label_remaining = QtWidgets.QLabel("--:--") self.label_remaining.setStyleSheet("font-weight: bold; color: black;") self.label_remaining.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) self.label_target = QtWidgets.QLabel() self.label_actual = QtWidgets.QLabel() self.label_masses = QtWidgets.QLabel() self.label_rate = QtWidgets.QLabel() self.label_stirrer = QtWidgets.QLabel() self.label_energy = QtWidgets.QLabel() for label in (self.label_target, self.label_actual, self.label_masses, self.label_rate, self.label_stirrer, self.label_energy): label.setStyleSheet("color: #555;") # A grid, not a single row - the Progress tab shares its pane's # width with the LCD panel on the Manual tab's side of the same # splitter (see Window.__init__), which only leaves room for two # short columns before needing a horizontal scrollbar. layout = QtWidgets.QGridLayout(self) layout.setColumnStretch(1, 1) layout.addWidget(self.led, 0, 0) layout.addWidget(self.label_title, 0, 1) layout.addWidget(self.label_remaining, 0, 2) layout.addWidget(self.label_target, 1, 0, 1, 2) layout.addWidget(self.label_actual, 1, 2) layout.addWidget(self.label_masses, 2, 0, 1, 2) layout.addWidget(self.label_energy, 2, 2) layout.addWidget(self.label_rate, 3, 0, 1, 2) layout.addWidget(self.label_stirrer, 3, 2) self.set_step(step, resolved_temp) self.set_actual_temp(None) self.set_live_stirrer(None) self.set_energy(None) def set_step(self, step, resolved_temp): """Applies this step's static, schedule-config fields - called once, right after construction (see __init__).""" step_pot = step.get('pot', {}) grain = step_pot.get('grain_mass', 0) water = step_pot.get('water_mass', 0) self.label_masses.setText("Grain {:.2f} kg / Water {:.2f} kg".format(grain, water)) ramp = step.get('ramp') or {} self.label_rate.setText("Rate {:.2f} °/min".format(ramp.get('rate', 0))) hold = step.get('hold') # The hold phase's speed, not the ramp phase's, as the "configured" # fallback shown for an inactive step - hold is the long-running # phase a glance at the plate cares about; ramp's own speed still # shows live (see set_live_stirrer()) while this step is actually # ramping. hold_stirrer = hold.get('stirrer', {}) if hold else {} self._configured_speed = hold_stirrer.get('speed', ramp.get('stirrer', {}).get('speed', 0)) self.label_target.setText( "Target {}".format("{:.1f} °C".format(resolved_temp) if resolved_temp is not None else "—")) def set_actual_temp(self, temp): self.label_actual.setText("Actual {}".format("{:.1f} °C".format(temp) if temp is not None else "—")) def set_energy(self, wh): """wh is this step's energy consumption so far (Wh) - live and still growing while this is the active step, frozen at its final total once finished, or None before this step has ever been reached (see Window._update_step_plates()).""" self.label_energy.setText("Energy {}".format("{:.0f} Wh".format(wh) if wh is not None else "—")) def set_live_stirrer(self, speed): """speed is the live rpm while this step is the active one, or None to fall back to its configured (hold-phase) speed for a step that's inactive (not yet reached, or already finished).""" if speed is not None: self.label_stirrer.setText("Stirrer {:.0f} rpm".format(speed)) else: self.label_stirrer.setText("Stirrer {:.0f} rpm (cfg)".format(self._configured_speed)) def set_remaining(self, seconds): self.label_remaining.setText(_format_duration(seconds) if seconds is not None else "--:--") def set_led(self, mode): self.led_mode = mode if mode == 'done': self._led_color(self.LED_GREEN) elif mode == 'ramp': self._led_color(self.LED_ORANGE) elif mode == 'hold': self._led_color(self.LED_GREEN) else: self._led_color(self.LED_OFF) def apply_blink(self, blink_on): """Ticked by Window's shared blink timer - only an active step's LED (mode 'ramp'/'hold') actually alternates; 'off'/'done' ignore it.""" if self.led_mode == 'ramp': self._led_color(self.LED_ORANGE if blink_on else self.LED_ORANGE_DIM) elif self.led_mode == 'hold': self._led_color(self.LED_GREEN if blink_on else self.LED_GREEN_DIM) def _led_color(self, color): self.led.setStyleSheet("background-color: {}; border-radius: 8px; border: 1px solid #222;".format(color)) 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 self._confirm_box = None # 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 # The last real 'Elapsed' value seen, full stop - unlike # sud_elapsed_seconds above, never reset back to None once the run # leaves a running state (that's specifically what the forecast # plot's dynamic-vs-static view switch needs - see _elapsed_min()), # so the status bar/Progress tab can still show where the run # actually ended up rather than "--:--" the moment it finishes. # Reset on a fresh Start/Load (see on_sud_changed()/ # update_sud_forecast()) - a new run's total shouldn't open by # showing the previous one's. self.sud_elapsed_last = None self.sud_name = '' self.sud_schedule = [] self.sud_pot_mass = 0 self.sud_grain_mass = 0 self.sud_water_mass = 0 self.sud_step_index = None self.sud_step_descr = None self.sud_step_type = None self.sud_hold_remaining = 0.0 # The last doc update_sud_forecast() actually processed - lets it # tell a genuinely different schedule (a real Load/New Sud) apart # from a standalone re-fetch of the *same*, already-running one # (e.g. connect()'s unconditional Save request) - see its own # comment for why that distinction matters. self.sud_last_loaded_doc = None # [(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 = [] # Per-step boundary times (absolute schedule index -> simulated # second), straight from the latest 'Forecast' message's # 'StepStarts' (see tasks/sud.py's SudTask.forecast_step_starts) - # drives the Progress tab's remaining-time labels (see # _update_step_plates()). sud_forecast_final_t is the forecast's # own last t, used as the implicit "end" of the last step once # sud_forecast_finished says the simulation actually reached it. self.sud_forecast_step_starts = {} self.sud_forecast_finished = False self.sud_forecast_final_t = None # One StepPlate per self.sud_schedule entry, same order/index - see # _rebuild_step_plates()/_update_step_plates(). self.step_plates = [] # Index -> the live actual temperature last seen while that step # was active, captured the moment a later 'Step' message reports # the schedule has moved past it (see on_sud_changed()) - lets a # finished step's plate keep showing where it actually ended up # rather than going blank. self.step_actual_frozen = {} # Live stirrer speed (rpm), tracked unconditionally (unlike # Slider_speed_soll, which only syncs once - see # on_stirrer_changed()) so the Progress tab's active-step plate can # show it. self.stirrer_speed_ist = 0.0 # Energy consumption (Wh), straight from the latest 'Energy' # message (see tasks/sud.py's SudTask._on_energy_changed()) - # sud_energy_by_step covers every *finished* step (absolute index # -> Wh), sud_energy_current is the running total for whichever # step is presently active. Unlike the timing/temperature fields # above, there's no forecast equivalent to preview before a run # starts - energy actually used can only be measured, not # predicted - so both stay empty/zero until one actually does. self.sud_energy_by_step = {} self.sud_energy_current = 0.0 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) # 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) # Show the last value we had before the GUI last closed (see # closeEvent()) right away, rather than the spinbox's bare design-time # default - blockSignals so this doesn't queue a spurious 'AmbientTemp' # send before the user has touched anything. remembered_ambient = self.user_config.get('ambient_temperature') if remembered_ambient is not None: self.doubleSpinBox_ambient.blockSignals(True) self.doubleSpinBox_ambient.setValue(remembered_ambient) self.doubleSpinBox_ambient.blockSignals(False) self.doubleSpinBox_ambient.valueChanged.connect(self.on_ambient_entry_changed) # Pre-dial the Pot reset target at the same starting point btn_pot_ # reset used to hardcode (ambient) - purely a one-off starting value # for the user to optionally dial away from before clicking the # button, so unlike the ambient spinbox it's never persisted/synced # again afterwards. if remembered_ambient is not None: self.doubleSpinBox_pot_temp.setValue(remembered_ambient) 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.label_pot_temp.setVisible(False) self.doubleSpinBox_pot_temp.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.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) # Progress tab - one StepPlate per schedule step, stacked inside a # scroll area (a long mash schedule can easily run past the visible # height). Built entirely in code rather than via brewpi.ui/ # main_window.py, since its content (the plates themselves) is # already fully dynamic - there's nothing for Designer to usefully # own here, unlike widget_plot/widget_plot_forecast above, which at # least have a fixed placeholder to hand a single child widget to. progress_tab = QtWidgets.QWidget() progress_layout = QtWidgets.QVBoxLayout(progress_tab) progress_layout.setContentsMargins(0, 0, 0, 0) progress_scroll = QtWidgets.QScrollArea() progress_scroll.setWidgetResizable(True) progress_container = QtWidgets.QWidget() self.progress_container_layout = QtWidgets.QVBoxLayout(progress_container) self.progress_container_layout.setSpacing(6) # Kept as the layout's last item by _rebuild_step_plates() (which # only ever inserts plates before it) - without it, fewer plates # than the scroll area's height would stretch to fill the gap # instead of stacking at the top. self.progress_container_layout.addStretch(1) progress_scroll.setWidget(progress_container) progress_layout.addWidget(progress_scroll) self.horizontalTabWidget.addTab(progress_tab, "Progress") # Single shared timer for every active plate's flashing LED - # mirrors status_label_cooling/cooling_blink_timer's pattern above. self.progress_blink_on = True self.progress_blink_timer = QtCore.QTimer(self) self.progress_blink_timer.timeout.connect(self.on_progress_blink) self.progress_blink_timer.start(500) 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.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 our own ambient temperature, in case the server restarted # since (and so fell back to its own config.json default) - the # spinbox's current value, not the on-disk user config (which is # only refreshed on shutdown - see closeEvent()), so this also picks # up a change made earlier in the same session, before a reconnect. self.msg_system.send({'AmbientTemp': self.doubleSpinBox_ambient.value()}) 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) 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._close_confirm_dialog() for w in (self.Slider_temp_soll, self.doubleSpinBox_heatrate_soll, self.Slider_pwr_soll, self.Slider_speed_soll): w.setEnabled(True) # ambient_temp (the server's last echoed reading, shown in the # status bar) is no longer trustworthy, but the spinbox itself is # a client-owned setting (see closeEvent()/connect()) - leave it # showing whatever the user last set rather than blanking it. self.ambient_temp = None self.btn_pot_reset.setVisible(False) self.label_pot_temp.setVisible(False) self.doubleSpinBox_pot_temp.setVisible(False) self.warp_factor = 1.0 self.sud_elapsed_seconds = None self.sud_name = '' self.sud_schedule = [] self.sud_pot_mass = 0 self.sud_grain_mass = 0 self.sud_water_mass = 0 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 spinbox while the user isn't actively # interacting with it, so a server echo doesn't clobber an # in-progress edit - blockSignals avoids this setValue() # itself re-triggering on_ambient_entry_changed() and # echoing the same value straight back. if not self.doubleSpinBox_ambient.hasFocus(): self.doubleSpinBox_ambient.blockSignals(True) self.doubleSpinBox_ambient.setValue(self.ambient_temp) self.doubleSpinBox_ambient.blockSignals(False) elif "WarpFactor" in key: self.warp_factor = msg['WarpFactor'] elif "PlantSim" in key: self.btn_pot_reset.setVisible(msg['PlantSim']) self.label_pot_temp.setVisible(msg['PlantSim']) self.doubleSpinBox_pot_temp.setVisible(msg['PlantSim']) self.update_status_env_label() def on_action_pot_reset(self): self.msg_pot.send({'Reset': self.doubleSpinBox_pot_temp.value()}) def on_ambient_entry_changed(self, value): # Persisted on shutdown instead (see closeEvent()), not on every # spinbox tick - this fires on every keystroke/arrow click while # editing. 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_progress_blink(self): self.progress_blink_on = not self.progress_blink_on for plate in self.step_plates: plate.apply_blink(self.progress_blink_on) 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_slider_stirrer_speed_soll_changed(self, value): print("on_slider_stirrer_speed_soll_changed {}".format(value)) self.msg_stirrer.send({'Speed': value}) 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): if self._confirm_box is not None: return dlg = QtWidgets.QDialog(self) dlg.setWindowTitle("Sud") dlg.setWindowModality(QtCore.Qt.NonModal) layout = QtWidgets.QVBoxLayout(dlg) layout.addWidget(QtWidgets.QLabel(message)) btn = QtWidgets.QPushButton("OK") btn.clicked.connect(lambda: self.msg_sud.send({'Confirm': True})) layout.addWidget(btn) self._confirm_box = dlg dlg.show() def _close_confirm_dialog(self): if self._confirm_box is not None: self._confirm_box.close() self._confirm_box = None 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, '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): pass 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(f'{submsg["Temp"]:.1f}') sud_running = self.sud_state in SUD_RUNNING_STATES if self.slider_temp_soll_initial_update or sud_running: self.Slider_temp_soll.blockSignals(True) self.Slider_temp_soll.setValue(int(round(submsg['Temp']))) self.Slider_temp_soll.blockSignals(False) if not sud_running: 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(f'{subsubmsg["Current"]:.1f}') if "Set" in subkey: sud_running = self.sud_state in SUD_RUNNING_STATES if self.heatrate_soll_initial_update or sud_running: self.doubleSpinBox_heatrate_soll.blockSignals(True) self.doubleSpinBox_heatrate_soll.setValue(subsubmsg['Set']) self.doubleSpinBox_heatrate_soll.blockSignals(False) if not sud_running: self.heatrate_soll_initial_update = False if "Ist" in key: submsg = msg['Ist'] if 'Temp' in submsg: self.plot_temp_ist = submsg['Temp'] self.lcdNumber_temp_ist.display(f'{submsg["Temp"]:.1f}') self._update_step_plates() if 'Rate' in submsg: self.plot_rate_ist = submsg['Rate'] self.lcdNumber_heatrate_ist.display(f'{submsg["Rate"]:.1f}') def on_heater_changed(self, msg): for key in msg: if "PowerSet" in key: self.plot_power_set = msg['PowerSet'] self.lcdNumber_power_heater.display(msg['PowerSet']) if self.sud_state in SUD_RUNNING_STATES: self.Slider_pwr_soll.blockSignals(True) self.Slider_pwr_soll.setValue(int(round(msg['PowerSet']))) self.Slider_pwr_soll.blockSignals(False) elif "Power" in key: self.plot_power_eff = msg['Power'] self.lcdNumber_power_pot.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 "Speed" in key: self.stirrer_speed_ist = msg['Speed'] self._update_step_plates() sud_running = self.sud_state in SUD_RUNNING_STATES if self.slider_speed_initial_update or sud_running: self.Slider_speed_soll.blockSignals(True) self.Slider_speed_soll.setValue(int(round(msg['Speed']))) self.Slider_speed_soll.blockSignals(False) if not sud_running: 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'] running = self.sud_state in SUD_RUNNING_STATES for w in (self.Slider_temp_soll, self.doubleSpinBox_heatrate_soll, self.Slider_pwr_soll, self.Slider_speed_soll): w.setEnabled(not running) if self.sud_state in SUD_RUNNING_STATES and self.sud_elapsed_seconds is None: self.sud_elapsed_seconds = 0.0 self.sud_elapsed_last = 0.0 # A genuine fresh start (excludes Pause->resume, which # never hits this branch - sud_elapsed_seconds is # already non-None throughout a pause) means whatever # energy the previous run banked belongs to that run, # not this one - mirrors tasks/sud.py's SudTask.recv() # resetting its own server-side bookkeeping on the same # event. The server will overwrite these with the # now-reset real figures within a tick or two regardless, # this just avoids a stale flash of the old run's totals # in between. self.sud_energy_by_step = {} self.sud_energy_current = 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 prev_state == SUD_WAIT_USER_STATE and self.sud_state != SUD_WAIT_USER_STATE: self._close_confirm_dialog() self._update_step_plates() elif "UserMessage" in key: self.sud_user_message = msg['UserMessage'] elif "Step" in key: step = msg['Step'] prev_index = self.sud_step_index new_index = step.get('Index') if prev_index is not None and new_index is not None and new_index > prev_index: # The step(s) just left (usually exactly one, but a hold # whose duration is already 0 can advance straight # through without this client ever seeing it as active - # see tasks/sud.py's _reanchor_forecast() comment on the # same race) freeze their last live actual temperature # for the Progress tab - see StepPlate.set_actual_temp(). for passed_index in range(prev_index, new_index): self.step_actual_frozen[passed_index] = self.plot_temp_ist self.sud_step_index = new_index self.sud_step_descr = step.get('Descr') self.sud_step_type = step.get('Type') self.update_status_step_label() self._update_step_plates() elif "HoldRemaining" in key: self.sud_hold_remaining = msg['HoldRemaining'] self.update_status_step_label() elif "Elapsed" in key: # Unlike sud_elapsed_seconds below, kept regardless of # dynamic-view mode - see its own comment for why - so the # status bar/Progress tab still have the real final total # once a run finishes rather than "--:--". self.sud_elapsed_last = msg['Elapsed'] self._update_step_plates() self.update_status_step_label() # 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'] # Seed the actual-trace history with this run's real # t=0 point - without it, the first sample only lands # at on_plot_timer()'s next 1-second tick, which under # a real sim_warp_factor can already be a noticeable # way into the run, leaving a small visible gap between # the forecast's exact t=0 anchor and where the actual # trace first appears. Gated on elapsed still being # small so reconnecting mid-brew (large elapsed) - where # this temperature reading has nothing to do with that # run's actual t=0 - doesn't get a bogus point plotted # at x=0. if not self.forecast_history and self.plot_temp_ist and msg['Elapsed'] < FRESH_RUN_ELAPSED_THRESHOLD_S: self.forecast_history.append((msg['Elapsed'] / 60.0, self.plot_temp_ist)) elif "Forecast" in key: self.on_sud_forecast_received(msg['Forecast']) elif "Energy" in key: self.sud_energy_by_step = dict(msg['Energy']['StepEnergy']) self.sud_energy_current = msg['Energy']['Current'] self._update_step_plates() self.update_status_step_label() 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 step transition (see tasks/ # sud.py's SudTask._reanchor_forecast()), 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']) self.sud_forecast_step_starts = dict(forecast['StepStarts']) self.sud_forecast_finished = forecast['Finished'] self.sud_forecast_final_t = forecast['T'][-1] if forecast['T'] else None self._update_step_plates() def update_status_step_label(self): if self.sud_step_descr: # +1: sud_step_index is the 0-based index Sud.index uses, but # StepPlate (the Progress tab) labels steps 1-based - match it # here so the status bar doesn't announce a step one number # below what the Progress tab shows for the same step. text = "Step {}: {}".format(self.sud_step_index + 1, 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) # sud_step_index is the same 0-based index Sud.index uses into # its own schedule list (see components/sud.py) - self. # sud_schedule is built from the very same doc, so it lines up # directly; grain_mass/water_mass are already resolved against # default.step there (see components/sud.py's _build_step()). if 0 <= self.sud_step_index < len(self.sud_schedule): step = self.sud_schedule[self.sud_step_index] step_pot = step.get('pot', {}) grain, water = step_pot.get('grain_mass', 0), step_pot.get('water_mass', 0) else: grain, water = self.sud_grain_mass, self.sud_water_mass elif self.sud_schedule: # Loaded but not started yet (no real Step message has # arrived) - preview the doc's own initial grain_mass/ # water_mass right away instead of leaving the status line # blank until Start, mirroring tasks/sud.py's SudTask. # apply_plant_params() applying it to the real plant/ # controller immediately on Load too. text = "" grain, water = self.sud_grain_mass, self.sud_water_mass else: self.statusBar().clearMessage() return pot = self.sud_pot_mass mass_text = "Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg".format( pot, water, grain, pot + water + grain) text = text + " " + mass_text if text else mass_text # Sums, not just the active step's own figures: total process time # is just self.sud_elapsed_last (Sud's own tick-counted total - # every step's time, including any WAIT_USER dwell, already adds # up into it sequentially with no gaps) - sud_elapsed_seconds # itself would go back to "--:--" the moment a run finishes, see # its own comment, which is exactly when this total matters most. # Total energy has no such single running counter server-side # (see tasks/sud.py's SudTask - energy is banked per step, not # accumulated as one grand total), so it's summed here from every # finished step's own Wh total plus the currently active one. elapsed_text = "Elapsed {}".format( _format_duration(self.sud_elapsed_last) if self.sud_elapsed_last is not None else "--:--") total_energy_kwh = (sum(self.sud_energy_by_step.values()) + self.sud_energy_current) / 1000.0 energy_text = "Energy {:.2f} kWh".format(total_energy_kwh) text = "{} {} {}".format(text, elapsed_text, energy_text) self.statusBar().showMessage(text) def update_sud_forecast(self, doc): try: name, _, schedule, pot_mass, _, _, _, grain_mass, water_mass = Sud._parse_data(doc) except (KeyError, TypeError): return # A genuinely different schedule (an actual Load, or "New Sud") # means no step has actually started yet, regardless of whatever # the previously loaded Sud last reported - don't wait for the # server's own 'Step' (None) reset (asyncio.create_task() there # means its arrival order relative to this doc isn't guaranteed) # to clear a stale step from the *previous* schedule before # showing this one's preview below. # # But this same 'Json' also arrives for the *same*, already- # running schedule - e.g. connect()'s unconditional Save request, # whose server-side reply is two standalone pushes (this Json, # then a Forecast - see tasks/sud.py's recv()), unlike the bundled # replay a subscribe triggers, which carries Step/State alongside # it to immediately fix up any such reset. Without this doc # equality check, that standalone Json would wipe sud_step_index # back to None with nothing in the same message to restore it - # exactly what made a freshly-connected client briefly highlight # (and then, once the next real Step/Elapsed push finally # arrived, fail to correct) the wrong step. is_new_schedule = (doc != self.sud_last_loaded_doc) self.sud_last_loaded_doc = doc self.sud_name = name self.sud_schedule = schedule self.sud_pot_mass = pot_mass self.sud_grain_mass = grain_mass self.sud_water_mass = water_mass self.sud_empty = len(schedule) == 0 if is_new_schedule: self.sud_step_descr = None self.sud_step_index = None self.sud_step_type = None self.sud_hold_remaining = 0.0 # Belongs to whatever schedule was loaded before - on_sud_ # forecast_received() re-fills these once a forecast for # *this* one arrives. self.sud_forecast_step_starts = {} self.sud_forecast_finished = False self.sud_forecast_final_t = None self.step_actual_frozen = {} self.sud_energy_by_step = {} self.sud_energy_current = 0.0 self.sud_elapsed_last = None self._rebuild_step_plates() self.update_sud_actions() self.update_status_step_label() 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 _rebuild_step_plates(self): """(Re)builds the Progress tab's stack of StepPlates from self.sud_schedule - called whenever a (possibly different) Sud is loaded (see update_sud_forecast()). Keeps the trailing stretch progress_container_layout was seeded with at construction (see Window.__init__) as its last item, so plates always stack at the top regardless of how few there are.""" while self.progress_container_layout.count() > 1: item = self.progress_container_layout.takeAt(0) widget = item.widget() if widget: widget.deleteLater() self.step_plates = [] resolved_temp = None for step in self.sud_schedule: if step.get('temperature') is not None: resolved_temp = step['temperature'] plate = StepPlate(len(self.step_plates), step, resolved_temp) self.step_plates.append(plate) self.progress_container_layout.insertWidget(self.progress_container_layout.count() - 1, plate) self._update_step_plates() def _update_step_plates(self): """Refreshes every StepPlate's LED/remaining-time/live fields from current state - called on every relevant push from the server (Step/State/Elapsed/Forecast on the Sud channel, Ist on TempCtrl, Speed on Stirrer). Cheap enough to just redo in full each time: a mash schedule is at most a handful of steps.""" if not self.step_plates: return # sud_elapsed_last, not sud_elapsed_seconds - the latter resets to # None the moment a run finishes (see its own comment), which # would otherwise make every finished step's remaining time jump # back to its full duration right when the run ends instead of # staying at 0:00. current_elapsed = self.sud_elapsed_last if self.sud_elapsed_last is not None else 0.0 starts = self.sud_forecast_step_starts n = len(self.step_plates) for i, plate in enumerate(self.step_plates): start_t = starts.get(i) end_t = starts.get(i + 1) if end_t is None and i == n - 1 and self.sud_forecast_finished: end_t = self.sud_forecast_final_t if start_t is not None and end_t is not None: total = max(0.0, end_t - start_t) plate.set_remaining(max(0.0, min(total, end_t - current_elapsed))) else: plate.set_remaining(None) if self.sud_step_index is None or i > self.sud_step_index: plate.set_led('off') elif i < self.sud_step_index: plate.set_led('done') else: # WAIT_USER/PAUSED don't change sud_step_type (see # components/sud.py's Sud._finish_step()/pause()) - it # stays whatever phase was actually last active, which is # exactly the color this step's LED should keep flashing. plate.set_led(self.sud_step_type if self.sud_step_type in ('ramp', 'hold') else 'hold') if self.sud_step_index is not None and i == self.sud_step_index: plate.set_actual_temp(self.plot_temp_ist) plate.set_live_stirrer(self.stirrer_speed_ist) elif i in self.step_actual_frozen: plate.set_actual_temp(self.step_actual_frozen[i]) plate.set_live_stirrer(None) else: plate.set_actual_temp(None) plate.set_live_stirrer(None) if self.sud_step_index is not None and i == self.sud_step_index: plate.set_energy(self.sud_energy_current) elif i in self.sud_energy_by_step: plate.set_energy(self.sud_energy_by_step[i]) else: plate.set_energy(None) def closeEvent(self, event): print("Exitting gracefully!") self.user_config.set('ambient_temperature', self.doubleSpinBox_ambient.value()) self.ws_client.disconnect() if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) win = Window() win.show() sys.exit(app.exec_()) print ("End of program.")