Files
brewpi/client/brewpi_gui.py
T
jens d91bba1ba2 Use the server's simulated forecast for the dynamic remainder too
SudForecastPlot.show_dynamic()'s dashed "remaining" line was still
using the naive abs(delta)/rate estimate even after a run started,
even though the (much more accurate) server-side
SudForecastEstimator was already available - the static forecast got
the upgrade, the live one didn't.

tasks/sud.py: SudTask now recomputes a forecast for just the remaining
steps (seeded from the live current temperature) on every step change
and sends it as {'RemainingForecast': {...}}. Building the synthetic
"rest of this step" entry for a step currently HOLDING needs to keep
the current step's grain_mass/water_mass/temperature/etc - a bare
{'hold': {...}} re-resolves those to None against the synthetic doc's
empty default.step, breaking derive_plant_params() (caught live: a
TypeError on every step change, silently swallowed by asyncio).

client/brewpi_gui.py: on_plot_timer() now uses the server's
RemainingForecast for the dashed projection whenever available,
falling back to the naive estimate only for the brief window before
each step's recomputation arrives. show_dynamic() takes the already-
computed (t_rem, theta_rem) instead of computing it internally, so the
GUI/server sources are interchangeable from its point of view.
2026-06-21 17:24:26 +02:00

910 lines
37 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_HOLDING_STATE = "SudState.HOLDING"
SUD_WAIT_USER_STATE = "SudState.WAIT_USER"
SUD_PAUSED_STATE = "SudState.PAUSED"
def _style_axis(ax):
ax.set_facecolor('white')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.xaxis.set_minor_locator(AutoMinorLocator())
ax.yaxis.set_minor_locator(AutoMinorLocator())
ax.tick_params(which='major', direction='out', labelsize='small')
ax.tick_params(which='minor', direction='out', length=2)
ax.grid(which='major', linestyle='-', linewidth=0.5, alpha=0.3)
ax.grid(which='minor', linestyle=':', linewidth=0.5, alpha=0.15)
class RealtimePlot(FigureCanvasQTAgg):
"""Live theta/heatrate/heater-power strip charts, mirroring figure 1 of
scripts/demos/sud/demo_sud.py but fed from the websocket connection
instead of an offline simulation run."""
def __init__(self):
figure = Figure(facecolor='white')
FigureCanvasQTAgg.__init__(self, figure)
self.ax_temp, self.ax_rate, self.ax_power = figure.subplots(3, 1, sharex=True)
self.line_temp_ist, = self.ax_temp.plot([], [], '-b', linewidth=1, label="theta_ist")
self.line_temp_soll, = self.ax_temp.plot([], [], '-r', linewidth=1, label="theta_soll")
self.ax_temp.set_ylabel("Temp [°C]")
self.ax_temp.legend(loc='upper left', fontsize='small')
self.line_rate_ist, = self.ax_rate.plot([], [], '-b', linewidth=1, label="heatrate_ist")
self.line_rate_soll, = self.ax_rate.plot([], [], '-r', linewidth=1, label="heatrate_soll")
self.ax_rate.set_ylabel("Rate [K/min]")
self.ax_rate.legend(loc='upper left', fontsize='small')
self.line_power_set, = self.ax_power.plot([], [], '-r', linewidth=1, label="power_set")
self.line_power_eff, = self.ax_power.plot([], [], '-b', linewidth=1, label="power_eff")
self.ax_power.set_ylabel("Power [W]")
self.ax_power.set_xlabel("t [s]")
self.ax_power.legend(loc='upper left', fontsize='small')
for ax in (self.ax_temp, self.ax_rate, self.ax_power):
_style_axis(ax)
# Only the bottom subplot needs x tick labels - sharex keeps the
# others in sync without repeating them.
self.ax_temp.label_outer()
self.ax_rate.label_outer()
figure.tight_layout()
self.reset()
def reset(self):
self.t0 = time.monotonic()
self.t = []
self.temp_ist = []
self.temp_soll = []
self.rate_ist = []
self.rate_soll = []
self.power_set = []
self.power_eff = []
def sample(self, temp_ist, temp_soll, rate_ist, rate_soll, power_set, power_eff):
self.t.append(time.monotonic() - self.t0)
self.temp_ist.append(temp_ist)
self.temp_soll.append(temp_soll)
self.rate_ist.append(rate_ist)
self.rate_soll.append(rate_soll)
self.power_set.append(power_set)
self.power_eff.append(power_eff)
self.line_temp_ist.set_data(self.t, self.temp_ist)
self.line_temp_soll.set_data(self.t, self.temp_soll)
self.line_rate_ist.set_data(self.t, self.rate_ist)
self.line_rate_soll.set_data(self.t, self.rate_soll)
self.line_power_set.set_data(self.t, self.power_set)
self.line_power_eff.set_data(self.t, self.power_eff)
for ax in (self.ax_temp, self.ax_rate, self.ax_power):
ax.relim()
ax.autoscale_view()
self.draw_idle()
class SudForecastPlot(FigureCanvasQTAgg):
"""A preview of a Sud schedule's temperature course, computed from its
nominal ramp rates/hold durations alone (no plant/controller model -
just how long the declared schedule says each step should take), so it
can be shown as soon as a schedule is loaded/edited, before a brew is
actually started (show_schedule()).
Once running, nonideal ramp rates/user pauses make that static estimate
drift from reality - show_dynamic() instead re-anchors the projection
every tick: the already-elapsed part is the actual measured trace
(line, solid), and only the remaining steps are projected forward from
the live current temperature/step/hold_remaining (line_projected,
dashed)."""
def __init__(self):
figure = Figure(facecolor='white')
FigureCanvasQTAgg.__init__(self, figure)
self.ax = figure.subplots(1, 1)
self.line, = self.ax.plot([], [], '-b', linewidth=1.5, zorder=2)
self.line_projected, = self.ax.plot([], [], '--b', linewidth=1.5, alpha=0.5, zorder=2)
self.progress_line = self.ax.axvline(0, color='g', linewidth=1.5, visible=False, zorder=2)
# Current temperature - drawn behind (lower zorder than) the
# forecast/progress lines so it never covers them.
self.current_temp_line = self.ax.axhline(0, color='r', linewidth=1.5, visible=False, zorder=1)
self.ax.set_xlabel("t [min]")
self.ax.set_ylabel("Temp [°C]")
_style_axis(self.ax)
figure.tight_layout()
@staticmethod
def _estimate_course(schedule, start_theta):
"""Walks the schedule, accumulating (t, theta) waypoints: ramps take
abs(delta)/rate minutes at the declared rate, holds last for their
duration at the temperature the preceding ramp reached. A step may
carry both - ramp then hold - contributing both segments in turn."""
t = [0.0]
theta = [start_theta]
for step in schedule:
ramp = step.get('ramp')
hold = step.get('hold')
if ramp is not None:
rate = ramp.get('rate', 0)
target = step['temperature']
if rate > 0:
t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0)
theta.append(target)
if hold is not None:
t.append(t[-1] + hold.get('duration', 0) * 60.0)
theta.append(theta[-1])
return t, theta
def show_schedule(self, schedule, start_theta, name):
"""Quick, approximate preview shown immediately when a schedule is
loaded - assumes every ramp instantly achieves/sustains its
declared rate, which real plants/controllers don't. Superseded by
show_precomputed_course() shortly after, once the server's
simulation-based forecast (components/sud_forecast.py) arrives."""
t, theta = self._estimate_course(schedule, start_theta)
t_min = [seconds / 60.0 for seconds in t]
self._show_static_course(t_min, theta, name)
def show_precomputed_course(self, t_min, theta, name):
"""Like show_schedule(), but (t_min, theta) were already computed
by the server simulating the schedule with its real plant/
controller - see Window.on_sud_forecast_received()."""
self._show_static_course(t_min, theta, name)
def _show_static_course(self, t_min, theta, name):
self.line.set_data(t_min, theta)
self.line_projected.set_data([], [])
self.ax.relim()
self.ax.autoscale_view()
self.ax.set_title("{} - {:.0f} min total".format(name, t_min[-1] if t_min else 0.0), fontsize='small')
self.figure.tight_layout()
self.draw_idle()
def show_dynamic(self, history, t_rem_min, theta_rem, name):
"""Re-anchored forecast for a run in progress: history is the
[(t_min, theta), ...] actually measured so far (drawn solid);
t_rem_min/theta_rem is the projected remainder (dashed), already
computed by the caller - either the server's simulation-based
SudForecastEstimator (preferred, see Window.on_plot_timer()) or,
until that arrives, a quick naive abs(delta)/rate fallback."""
hist_t = [t for t, _ in history]
hist_theta = [theta for _, theta in history]
self.line.set_data(hist_t, hist_theta)
self.line_projected.set_data(t_rem_min, theta_rem)
self.ax.relim()
self.ax.autoscale_view()
total_min = t_rem_min[-1] if t_rem_min else (hist_t[-1] if hist_t else 0.0)
self.ax.set_title("{} - {:.0f} min total (est.)".format(name, total_min), fontsize='small')
self.figure.tight_layout()
self.draw_idle()
def show_no_schedule(self):
self.line.set_data([], [])
self.line_projected.set_data([], [])
self.clear_progress()
self.ax.relim()
self.ax.autoscale_view()
self.ax.set_title("No sud loaded", fontsize='small')
self.figure.tight_layout()
self.draw_idle()
def set_progress(self, t_min):
self.progress_line.set_xdata([t_min, t_min])
self.progress_line.set_visible(True)
self.draw_idle()
def clear_progress(self):
self.progress_line.set_visible(False)
self.draw_idle()
def set_current_temp(self, theta):
self.current_temp_line.set_ydata([theta, theta])
self.current_temp_line.set_visible(True)
self.draw_idle()
def clear_current_temp(self):
self.current_temp_line.set_visible(False)
self.draw_idle()
class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# on_sud_changed() runs on the websocket recv thread, but a modal dialog
# must be shown from the GUI thread - a signal/slot crosses that thread
# boundary safely (Qt auto-queues the slot call onto the receiver's
# thread when sender and receiver differ).
user_message_signal = QtCore.pyqtSignal(str)
def __init__(self):
QtWidgets.QMainWindow.__init__(self)
self.setWindowIcon(QtGui.QIcon('res/beer.png'))
self.user_message_signal.connect(self.on_user_message_signal)
self.user_config = UserConfig()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self.msg_dispatch = MessageDispatcherSync(auto_subscribe=True)
self.msg_dispatch.on_connect_changed = self.on_ws_connect_changed
self.ws_client = WsClient(listener=self.msg_dispatch, loop=loop)
self.connected = False
self.sud_loaded = False
# Whether the loaded schedule has zero steps (e.g. just after "New
# Sud") - distinct from sud_loaded, which just means a Sud is
# configured server-side at all. Defaults True so Start/Pause/Stop
# stay disabled until a non-empty schedule is actually confirmed.
self.sud_empty = True
self.sud_state = None
self.sud_user_message = None
# Mirrors the server's TempController.enabled - gates the
# temp/heatrate setpoint controls (see on_tempctrl_changed()).
self.tc_enabled = False
# Global, not Sud-specific - from the 'System' channel's one-time
# startup message. warp_factor defaults to 1.0 (no scaling) until
# that arrives, or for a server that doesn't send it at all.
self.ambient_temp = None
self.warp_factor = 1.0
# time.monotonic() of the last IDLE/DONE -> running transition, used
# to position the forecast plot's progress line; None while not
# running. sud_paused_total accumulates time spent PAUSED so the
# progress line freezes during a pause instead of jumping ahead;
# sud_pause_started_at is when the current pause began, if any.
self.sud_start_time = None
self.sud_paused_total = 0.0
self.sud_pause_started_at = None
# Resolved schedule + live progress within it, kept around so the
# forecast plot can be dynamically re-anchored every tick instead of
# only estimated once when the schedule was (re)loaded - see
# refresh_forecast()/SudForecastPlot.show_dynamic().
self.sud_name = ''
self.sud_schedule = []
self.sud_step_index = None
self.sud_step_descr = None
self.sud_step_type = None
self.sud_hold_remaining = 0.0
# [(t_min, theta), ...] actually measured since the current run
# started - the "already happened" part of the dynamic forecast.
self.forecast_history = []
# (T, Theta) from the server's simulation-based estimate of the
# remaining steps, recomputed on every step change - None until it
# arrives (or after a step change, until the next one does), in
# which case on_plot_timer() falls back to the naive estimate.
self.server_remaining_forecast = None
self.msg_pot = self.msg_dispatch.msgio_get('Pot')
self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
self.msg_heater = self.msg_dispatch.msgio_get('Heater')
self.msg_stirrer = self.msg_dispatch.msgio_get('Stirrer')
self.msg_tempctrl = self.msg_dispatch.msgio_get('TempCtrl')
self.msg_sud = self.msg_dispatch.msgio_get('Sud')
self.msg_system = self.msg_dispatch.msgio_get('System')
self.msg_pot.set_recv_handler(self.on_pot_changed)
self.msg_sensor.set_recv_handler(self.on_sensor_changed)
self.msg_heater.set_recv_handler(self.on_heater_changed)
self.msg_stirrer.set_recv_handler(self.on_stirrer_changed)
self.msg_tempctrl.set_recv_handler(self.on_tempctrl_changed)
self.msg_sud.set_recv_handler(self.on_sud_changed)
self.msg_system.set_recv_handler(self.on_system_changed)
self.setupUi(self)
# Matches tc_enabled's default (False) until the server says otherwise.
self.Slider_temp_soll.setEnabled(False)
self.doubleSpinBox_heatrate_soll.setEnabled(False)
# Permanent (right-aligned, not cleared by showMessage()/clearMessage())
# label for static environment info - distinct from the status bar's
# transient step/schedule messages.
self.status_label_env = QtWidgets.QLabel()
self.statusBar().addPermanentWidget(self.status_label_env)
self.update_status_env_label()
# Blinking indicator for the temperature controller's own COOL
# state (target below the current temperature - the heater is
# forced off server-side and we just wait for ambient loss to
# catch up).
self.tc_cooling = False
self.status_label_cooling = QtWidgets.QLabel("Cool down")
self.status_label_cooling.setStyleSheet("color: blue; font-weight: bold;")
self.status_label_cooling.setVisible(False)
self.statusBar().addPermanentWidget(self.status_label_cooling)
self.cooling_blink_timer = QtCore.QTimer(self)
self.cooling_blink_timer.timeout.connect(self.on_cooling_blink)
self.update_sud_actions()
self.btn_connect.clicked.connect(self.on_btn_connect_clicked)
self.actionStart.triggered.connect(self.on_action_sud_start)
self.actionPause.triggered.connect(self.on_action_sud_pause)
self.actionStop.triggered.connect(self.on_action_sud_stop)
self.actionSudNew.triggered.connect(self.on_action_sud_new)
self.actionSudSave.triggered.connect(self.on_action_sud_save)
self.actionSudLoad.triggered.connect(self.on_action_sud_load)
self.Slider_pwr_soll.valueChanged.connect(self.on_slider_pwr_soll_changed)
self.Slider_temp_soll.valueChanged.connect(self.on_slider_temp_soll_changed)
self.Slider_speed_soll.valueChanged.connect(self.on_slider_stirrer_speed_soll_changed)
self.doubleSpinBox_heatrate_soll.valueChanged.connect(self.on_heatrate_soll_changed)
self.checkbox_heater_activate.stateChanged.connect(self.on_checkbox_changed)
self.checkbox_stirrer_activate.stateChanged.connect(self.on_checkbox_stirrer_activate_changed)
self.checkbox_tc_enable.stateChanged.connect(self.on_checkbox_tc_enable_changed)
self.lineEdit_ambient.returnPressed.connect(self.on_ambient_entry_changed)
self.btn_pot_reset.clicked.connect(self.on_action_pot_reset)
# Only meaningful (and only shown) once the server confirms its Pot
# is simulated - see on_system_changed()'s "PlantSim" handling.
self.btn_pot_reset.setVisible(False)
self.send_data = Queue()
self.slider_pwr_initial_update = True
self.slider_temp_soll_initial_update = True
self.slider_speed_initial_update = True
self.checkbox_heater_activate_initial_update = True
self.checkbox_stirrer_activate_initial_update = True
self.heatrate_soll_initial_update = True
self.sud_save_path = None
# Latest values sampled by the plot timer - written from the
# websocket recv thread, read from the GUI thread; plain floats so
# no locking, consistent with how the rest of the GUI is wired.
self.plot_temp_ist = 0.0
self.plot_temp_soll = 0.0
self.plot_rate_ist = 0.0
self.plot_rate_soll = 0.0
self.plot_power_set = 0.0
self.plot_power_eff = 0.0
self.plot = RealtimePlot()
plot_layout = QtWidgets.QVBoxLayout(self.widget_plot)
plot_layout.setContentsMargins(0, 0, 0, 0)
plot_layout.addWidget(self.plot)
self.plot_timer = QtCore.QTimer(self)
self.plot_timer.timeout.connect(self.on_plot_timer)
self.plot_timer.start(1000)
self.forecast_plot = SudForecastPlot()
forecast_layout = QtWidgets.QVBoxLayout(self.widget_plot_forecast)
forecast_layout.setContentsMargins(0, 0, 0, 0)
forecast_layout.addWidget(self.forecast_plot)
def connect(self):
self.slider_pwr_initial_update = True
self.slider_temp_soll_initial_update = True
self.slider_speed_initial_update = True
self.checkbox_heater_activate_initial_update = True
self.checkbox_stirrer_activate_initial_update = True
self.heatrate_soll_initial_update = True
self.plot.reset()
self.ws_client.connect(uri=self.plainTextUri.toPlainText())
# Fetch the schedule for the Automatic tab's forecast preview - not
# a user-initiated save, so sud_save_path stays None and
# on_sud_changed() routes the reply to update_sud_forecast() instead
# of a save dialog.
self.msg_sud.send({'Save': True})
# Re-apply the last ambient temperature we set, in case the server
# restarted since (and so fell back to its own config.json default).
remembered_ambient = self.user_config.get('ambient_temperature')
if remembered_ambient is not None:
self.msg_system.send({'AmbientTemp': remembered_ambient})
def on_plot_timer(self):
self.plot.sample(
self.plot_temp_ist, self.plot_temp_soll,
self.plot_rate_ist, self.plot_rate_soll,
self.plot_power_set, self.plot_power_eff)
if self.sud_start_time is not None:
# While paused, use the moment the pause began as "now" so the
# line freezes instead of drifting ahead of actual progress.
now = self.sud_pause_started_at if self.sud_pause_started_at is not None else time.monotonic()
elapsed_real_s = now - self.sud_start_time - self.sud_paused_total
# The forecast's x-axis is simulated-schedule time; scale real
# elapsed time by the server's warp factor to match it.
elapsed_min = elapsed_real_s * self.warp_factor / 60.0
self.forecast_plot.set_progress(elapsed_min)
# Re-anchor the forecast from the live state instead of redrawing
# the static, increasingly-stale estimate from t=0. Frozen while
# PAUSED (elapsed_min isn't advancing either, and the paused
# phase - ramp or hold - isn't visible to the client to resume
# the projection from correctly) - just leave the last draw up.
if self.sud_state != SUD_PAUSED_STATE and self.plot_temp_ist:
self.forecast_history.append((elapsed_min, self.plot_temp_ist))
if self.server_remaining_forecast is not None:
t_rem, theta_rem = self.server_remaining_forecast
else:
# Server's simulation-based remainder hasn't arrived
# yet (it's recomputed on every step change, off the
# event loop) - fall back to the quick naive estimate
# for this one tick.
t_rem, theta_rem = self.forecast_plot._estimate_course(self._remaining_schedule(), self.plot_temp_ist)
t_rem_min = [elapsed_min + seconds / 60.0 for seconds in t_rem]
self.forecast_plot.show_dynamic(self.forecast_history, t_rem_min, theta_rem, self.sud_name)
else:
self.forecast_plot.clear_progress()
if self.plot_temp_ist:
self.forecast_plot.set_current_temp(self.plot_temp_ist)
else:
self.forecast_plot.clear_current_temp()
def _remaining_schedule(self):
"""The not-yet-done part of sud_schedule, for SudForecastPlot's
dynamic re-anchoring: the current step's already-finished phase is
dropped, and its still-to-run phase is left for _estimate_course()
to size from the live theta_now/hold_remaining it's seeded with,
rather than the step's nominal start."""
schedule = self.sud_schedule
index = self.sud_step_index
if not schedule or index is None or not (0 <= index < len(schedule)):
return []
current = schedule[index]
rest = schedule[index + 1:]
if self.sud_state == SUD_WAIT_USER_STATE:
# The current step (both its ramp and hold, if any) is already
# done - only waiting on the user to advance.
return rest
if self.sud_state == SUD_HOLDING_STATE:
remaining_minutes = max(self.sud_hold_remaining, 0.0) / 60.0
return [{'hold': {'duration': remaining_minutes}}] + rest
# RAMPING: _estimate_course() recomputes the ramp's remaining
# distance from whatever start_theta it's given, so the step is
# used as-is; its hold phase (if any) hasn't started yet either.
return [current] + rest
def disconnect(self):
# ws_client.disconnect() blocks until the websocket's close handshake
# completes (or times out, ~10s if the server doesn't ack promptly),
# joining the connection's background thread - run it off a throwaway
# thread so that wait doesn't freeze the GUI. on_ws_connect_changed()
# still flips the button once the disconnect actually finishes.
threading.Thread(target=self.ws_client.disconnect, daemon=True).start()
def on_btn_connect_clicked(self):
if self.connected:
self.disconnect()
else:
self.connect()
def on_ws_connect_changed(self, connected):
self.connected = connected
self.btn_connect.setText("Disconnect" if connected else "Connect")
if not connected:
# Nothing reported over a dead connection can be trusted anymore.
self.sud_loaded = False
self.sud_empty = True
self.sud_state = None
self.sud_user_message = None
self.tc_enabled = False
self.checkbox_tc_enable.blockSignals(True)
self.checkbox_tc_enable.setChecked(False)
self.checkbox_tc_enable.blockSignals(False)
self.Slider_temp_soll.setEnabled(False)
self.doubleSpinBox_heatrate_soll.setEnabled(False)
self.ambient_temp = None
self.lineEdit_ambient.clear()
self.btn_pot_reset.setVisible(False)
self.warp_factor = 1.0
self.sud_start_time = None
self.sud_paused_total = 0.0
self.sud_pause_started_at = None
self.sud_name = ''
self.sud_schedule = []
self.sud_step_index = None
self.sud_step_descr = None
self.sud_step_type = None
self.sud_hold_remaining = 0.0
self.forecast_history = []
self.server_remaining_forecast = None
self.set_tc_cooling(False)
self.update_sud_actions()
self.update_status_env_label()
self.setWindowTitle("BrewPi")
def update_sud_actions(self):
can_run = self.sud_loaded and not self.sud_empty
running = can_run and self.sud_state in SUD_RUNNING_STATES
paused = can_run and self.sud_state == SUD_PAUSED_STATE
# Start also doubles as Resume while paused; Stop can still abort a
# paused run, but there's nothing left to Pause once already paused.
self.actionStart.setEnabled(can_run and not running)
self.actionPause.setEnabled(running)
self.actionStop.setEnabled(running or paused)
def on_system_changed(self, msg):
print("on_system_changed {}".format(msg))
for key in msg:
if "AmbientTemp" in key:
self.ambient_temp = msg['AmbientTemp']
# Only sync the entry's text while the user isn't actively
# editing it, so a server echo doesn't clobber in-progress
# typing.
if not self.lineEdit_ambient.hasFocus():
self.lineEdit_ambient.setText("{:.1f}".format(self.ambient_temp))
elif "WarpFactor" in key:
self.warp_factor = msg['WarpFactor']
elif "PlantSim" in key:
self.btn_pot_reset.setVisible(msg['PlantSim'])
self.update_status_env_label()
def on_action_pot_reset(self):
self.msg_pot.send({'Reset': True})
def on_ambient_entry_changed(self):
try:
value = float(self.lineEdit_ambient.text())
except ValueError:
return
self.user_config.set('ambient_temperature', value)
self.msg_system.send({'AmbientTemp': value})
def update_status_env_label(self):
ambient = "{:.1f}°C".format(self.ambient_temp) if self.ambient_temp is not None else "-"
self.status_label_env.setText("Ambient: {} Warp: {:.1f}x".format(ambient, self.warp_factor))
@staticmethod
def _make_window_title(name, description):
title = "BrewPi"
if name:
title += " - {}".format(name)
if description:
title += " - {}".format(description)
return title
def set_tc_cooling(self, cooling):
if cooling == self.tc_cooling:
return
self.tc_cooling = cooling
if cooling:
self.status_label_cooling.setVisible(True)
self.cooling_blink_timer.start(500)
else:
self.cooling_blink_timer.stop()
self.status_label_cooling.setVisible(False)
def on_cooling_blink(self):
self.status_label_cooling.setVisible(not self.status_label_cooling.isVisible())
def on_slider_temp_soll_changed(self, value):
print("on_slider_temp_soll_changed {}".format(value))
self.msg_tempctrl.send({'Soll': {'Temp': value}})
def on_heatrate_soll_changed(self, value):
value = round(value, 1)
print("on_textbox_heatrate_soll_changed {}".format(value))
self.msg_tempctrl.send({'Soll': {'Rate': value}})
def on_slider_pwr_soll_changed(self, value):
print("on_slider_pwr_soll_changed {}".format(value))
self.msg_heater.send({'Power': value})
def on_checkbox_changed(self, value):
print("on_checkbox_changed {}".format(value))
self.msg_heater.send({'Activate': int(value == 2)})
def on_slider_stirrer_speed_soll_changed(self, value):
print("on_slider_stirrer_speed_soll_changed {}".format(value))
self.msg_stirrer.send({'Speed': value})
def on_checkbox_stirrer_activate_changed(self, value):
print("on_checkbox_stirrer_activate_changed {}".format(value))
self.msg_stirrer.send({'Activate': int(value == 2)})
def on_checkbox_tc_enable_changed(self, value):
print("on_checkbox_tc_enable_changed {}".format(value))
self.msg_tempctrl.send({'Enable': value == 2})
def on_action_sud_start(self):
self.msg_sud.send({'Start': True})
def on_action_sud_pause(self):
self.msg_sud.send({'Pause': True})
def on_action_sud_stop(self):
self.msg_sud.send({'Stop': True})
def on_user_message_signal(self, message):
# Runs on the GUI thread (queued there by the cross-thread signal
# emit in on_sud_changed()). The Sud is already blocked in
# WAIT_USER server-side; closing this dialog is what unblocks it.
QtWidgets.QMessageBox.information(self, "Sud", message)
self.msg_sud.send({'Confirm': True})
def on_action_sud_new(self):
# Replaces the running Sud's schedule with an empty one, same as
# Load but with a built-in empty document instead of a file dialog.
self.msg_sud.send({'Load': {'Name': '', 'Description': '', 'pot_mass': 0, 'pot_material': None, 'steps': []}})
def on_action_sud_save(self):
# Pick the destination up front, on the GUI thread - the actual
# write happens in on_sud_changed(), which runs off a worker thread
# and must not touch Qt (e.g. open a file dialog) itself.
start_dir = self.user_config.get('sud_dir', '')
path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Sud", start_dir, filter="JSON files (*.json)")
if not path:
return
self.user_config.set('sud_dir', str(Path(path).parent))
self.sud_save_path = path
self.msg_sud.send({'Save': True})
def on_action_sud_load(self):
start_dir = self.user_config.get('sud_dir', '')
path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load Sud", start_dir, filter="JSON files (*.json)")
if not path:
return
self.user_config.set('sud_dir', str(Path(path).parent))
with open(path) as f:
data = json.load(f)
self.msg_sud.send({'Load': data})
def on_pot_changed(self, msg):
print("on_pot_changed {}".format(msg))
for key in msg:
if "Power" in key:
self.lcdNumber_power_pot.display(msg['Power'])
def on_sensor_changed(self, msg):
print("on_sensor_changed {}".format(msg))
def on_tempctrl_changed(self, msg):
print("on_tempctrl_changed {}".format(msg))
for key in msg:
if 'State' in key:
self.label_state.setText(msg['State'])
self.set_tc_cooling(msg['State'] == 'States.COOL')
elif "Soll" in key:
submsg = msg['Soll']
if 'Temp' in submsg:
self.plot_temp_soll = submsg['Temp']
self.lcdNumber_temp_soll.display(str(submsg['Temp']))
if self.slider_temp_soll_initial_update:
# blockSignals: setValue() here is just syncing the
# slider's display to the server's current value, not
# a user edit - without this it would also fire
# valueChanged and echo the value straight back as a
# command.
self.Slider_temp_soll.blockSignals(True)
self.Slider_temp_soll.setValue(int(round(submsg['Temp'])))
self.Slider_temp_soll.blockSignals(False)
self.slider_temp_soll_initial_update = False
if 'Rate' in submsg:
subsubmsg = submsg['Rate']
for subkey in subsubmsg:
if "Current" in subkey:
self.plot_rate_soll = subsubmsg['Current']
self.lcdNumber_heatrate_soll.display(str(subsubmsg['Current']))
if "Set" in subkey:
if self.heatrate_soll_initial_update:
self.heatrate_soll_initial_update = False
self.doubleSpinBox_heatrate_soll.blockSignals(True)
self.doubleSpinBox_heatrate_soll.setValue(subsubmsg['Set'])
self.doubleSpinBox_heatrate_soll.blockSignals(False)
elif "Enabled" in key:
self.tc_enabled = msg['Enabled']
self.checkbox_tc_enable.blockSignals(True)
self.checkbox_tc_enable.setChecked(self.tc_enabled)
self.checkbox_tc_enable.blockSignals(False)
self.Slider_temp_soll.setEnabled(self.tc_enabled)
self.doubleSpinBox_heatrate_soll.setEnabled(self.tc_enabled)
if "Ist" in key:
submsg = msg['Ist']
if 'Temp' in submsg:
self.plot_temp_ist = submsg['Temp']
self.lcdNumber_temp_ist.display(str(submsg['Temp']))
if 'Rate' in submsg:
self.plot_rate_ist = submsg['Rate']
self.lcdNumber_heatrate_ist.display(str(submsg['Rate']))
def on_heater_changed(self, msg):
print("on_heater_changed {}".format(msg))
for key in msg:
if "Activate" in key:
if self.checkbox_heater_activate_initial_update:
self.checkbox_heater_activate.blockSignals(True)
self.checkbox_heater_activate.setCheckState(2 if msg['Activate'] == 1 else 0)
self.checkbox_heater_activate.blockSignals(False)
self.checkbox_heater_activate_initial_update = False
elif "PowerSet" in key:
self.plot_power_set = msg['PowerSet']
elif "Power" in key:
self.plot_power_eff = msg['Power']
self.lcdNumber_power_heater.display(msg['Power'])
if self.slider_pwr_initial_update:
# blockSignals: this is a display-only sync of the
# slider to the heater's current power, not a manual
# override - letting valueChanged fire here would echo
# it back as {'Power': ...}, which tasks/heater.py's
# power_soll = max(power_soll, power_actor) latches as a
# floor that never goes back down on its own. That's the
# "stale power > 0 after reconnect" bug.
self.Slider_pwr_soll.blockSignals(True)
self.Slider_pwr_soll.setValue(int(round(msg['Power'])))
self.Slider_pwr_soll.blockSignals(False)
self.slider_pwr_initial_update = False
elif "Capabilities" in key:
submsg = msg['Capabilities']
if "Power" in submsg:
submsg = submsg['Power']
self.Slider_pwr_soll.setMinimum(int(submsg['Min']))
self.Slider_pwr_soll.setMaximum(int(submsg['Max']))
def on_stirrer_changed(self, msg):
print("on_stirrer_changed {}".format(msg))
for key in msg:
if "Activate" in key:
if self.checkbox_stirrer_activate_initial_update:
self.checkbox_stirrer_activate.blockSignals(True)
self.checkbox_stirrer_activate.setCheckState(2 if msg['Activate'] == 1 else 0)
self.checkbox_stirrer_activate.blockSignals(False)
self.checkbox_stirrer_activate_initial_update = False
elif "Speed" in key:
if self.slider_speed_initial_update:
self.Slider_speed_soll.blockSignals(True)
self.Slider_speed_soll.setValue(int(round(msg['Speed'])))
self.Slider_speed_soll.blockSignals(False)
self.slider_speed_initial_update = False
elif "Capabilities" in key:
submsg = msg['Capabilities']
if "Power" in submsg:
submsg = submsg['Power']
self.Slider_speed_soll.setMinimum(int(submsg['Min']))
self.Slider_speed_soll.setMaximum(int(submsg['Max']))
def on_sud_changed(self, msg):
print("on_sud_changed {}".format(msg))
# Receiving anything at all on this channel means a Sud is loaded
# server-side - the schedule it's running may still be coming.
self.sud_loaded = True
for key in msg:
if "Json" in key:
if self.sud_save_path:
with open(self.sud_save_path, 'w') as f:
json.dump(msg['Json'], f, indent='\t')
self.sud_save_path = None
else:
self.update_sud_forecast(msg['Json'])
elif "Name" in key:
self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
self.setWindowTitle(self._make_window_title(msg.get('Name', ''), msg.get('Description', '')))
elif "State" in key:
prev_state = self.sud_state
self.sud_state = msg['State']
if self.sud_state == SUD_PAUSED_STATE and prev_state != SUD_PAUSED_STATE:
self.sud_pause_started_at = time.monotonic()
elif self.sud_state != SUD_PAUSED_STATE and prev_state == SUD_PAUSED_STATE:
self.sud_paused_total += time.monotonic() - self.sud_pause_started_at
self.sud_pause_started_at = None
if self.sud_state in SUD_RUNNING_STATES and self.sud_start_time is None:
self.sud_start_time = time.monotonic()
# Start the dynamic forecast's measured-history trace
# fresh for this run.
self.forecast_history = []
elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE:
was_running = self.sud_start_time is not None
self.sud_start_time = None
self.sud_paused_total = 0.0
self.sud_pause_started_at = None
# on_plot_timer() only redraws the dynamic forecast while
# sud_start_time is set.
if was_running:
# A run just finished or was stopped - leave the
# dynamic view's last frame (the actual measured
# history) up rather than recomputing a "static"
# plan from here, which would walk the *whole*
# original schedule again starting from wherever
# the brew happens to have ended up (e.g. close to
# the last step's target), producing a nonsensical
# total.
pass
elif self.sud_schedule:
# Fresh load (or reconnecting to an already-idle
# server) - no run has happened yet, so the upfront
# plan starting from the current temperature is
# exactly right.
start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0
self.forecast_plot.show_schedule(self.sud_schedule, start_theta, self.sud_name)
if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message:
self.user_message_signal.emit(self.sud_user_message)
elif "UserMessage" in key:
self.sud_user_message = msg['UserMessage']
elif "Step" in key:
step = msg['Step']
self.sud_step_index = step.get('Index')
self.sud_step_descr = step.get('Descr')
self.sud_step_type = step.get('Type')
# Stale for the step that just ended - the server is
# already recomputing it (see on_plot_timer()'s fallback
# to the naive estimate in the meantime).
self.server_remaining_forecast = None
self.update_status_step_label()
elif "HoldRemaining" in key:
self.sud_hold_remaining = msg['HoldRemaining']
self.update_status_step_label()
elif "RemainingForecast" in key:
forecast = msg['RemainingForecast']
self.server_remaining_forecast = (forecast['T'], forecast['Theta'])
elif "Forecast" in key:
self.on_sud_forecast_received(msg['Forecast'])
self.update_sud_actions()
def on_sud_forecast_received(self, forecast):
# The server computes this asynchronously (it simulates the whole
# schedule) and it can arrive slightly after the schedule itself -
# only apply it if we're still looking at the static "not running
# yet" preview show_schedule() drew; a run already in progress (or
# one that's since finished) owns the plot via show_dynamic()/its
# frozen last frame instead.
if self.sud_empty or self.sud_start_time is not None:
return
t_min = [seconds / 60.0 for seconds in forecast['T']]
self.forecast_plot.show_precomputed_course(t_min, forecast['Theta'], self.sud_name)
def update_status_step_label(self):
if not self.sud_step_descr:
self.statusBar().clearMessage()
return
text = "Step {}: {}".format(self.sud_step_index, self.sud_step_descr)
if self.sud_step_type == 'hold':
remaining = max(self.sud_hold_remaining, 0.0)
text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60)
self.statusBar().showMessage(text)
def update_sud_forecast(self, doc):
try:
name, _, schedule, _, _ = Sud._parse_data(doc)
except (KeyError, TypeError):
return
self.sud_name = name
self.sud_schedule = schedule
self.sud_empty = len(schedule) == 0
self.update_sud_actions()
if self.sud_empty:
self.forecast_plot.show_no_schedule()
return
# No measured starting temperature is available before a brew has
# started; fall back to a plausible ambient guess. If a run is
# already in progress (e.g. reconnecting mid-brew), the next
# on_plot_timer() tick switches this over to the dynamic view.
start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0
self.forecast_plot.show_schedule(schedule, start_theta, name)
def closeEvent(self, event):
print("Exitting gracefully!")
self.ws_client.disconnect()
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec_())
print ("End of program.")