Files
brewpi/client/brewpi_gui.py
T
jensandClaude Sonnet 4.6 dd6193fa46 Remove manual tab override/enable checkboxes from browser and PyQt clients
Drops the tc-enable, heater-activate and stirrer-activate checkboxes
and all associated state, signal connections, initial-sync flags, and
channel-handler logic. Regenerates main_window.py from the updated .ui.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DF16nV2ispNw1C9SbvqbRQ
2026-06-27 20:37:14 +02:00

1350 lines
58 KiB
Python
Executable File

#!/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()
# 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}')
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(f'{subsubmsg["Current"]:.1f}')
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)
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'])
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()
if self.slider_speed_initial_update:
self.Slider_speed_soll.blockSignals(True)
self.Slider_speed_soll.setValue(int(round(msg['Speed'])))
self.Slider_speed_soll.blockSignals(False)
self.slider_speed_initial_update = False
elif "Capabilities" in key:
submsg = msg['Capabilities']
if "Power" in submsg:
submsg = submsg['Power']
self.Slider_speed_soll.setMinimum(int(submsg['Min']))
self.Slider_speed_soll.setMaximum(int(submsg['Max']))
def on_sud_changed(self, msg):
print("on_sud_changed {}".format(msg))
# Receiving anything at all on this channel means a Sud is loaded
# server-side - the schedule it's running may still be coming.
self.sud_loaded = True
for key in msg:
if "Json" in key:
if self.sud_save_path:
with open(self.sud_save_path, 'w') as f:
json.dump(msg['Json'], f, indent='\t')
self.sud_save_path = None
else:
self.update_sud_forecast(msg['Json'])
elif "Name" in key:
self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
self.setWindowTitle(self._make_window_title(msg.get('Name', ''), msg.get('Description', '')))
elif "State" in key:
prev_state = self.sud_state
self.sud_state = msg['State']
if self.sud_state in SUD_RUNNING_STATES and self.sud_elapsed_seconds is None:
self.sud_elapsed_seconds = 0.0
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.")