Caption becomes "BrewPi - <name> - <description>" once a schedule with both is loaded, degrading gracefully to just the name, or plain "BrewPi", when either is empty.
660 lines
25 KiB
Python
Executable File
660 lines
25 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
|
|
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
|
|
|
|
# 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"
|
|
|
|
|
|
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 static 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."""
|
|
|
|
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)
|
|
self.progress_line = self.ax.axvline(0, color='r', linewidth=1.5, visible=False)
|
|
self.ax.set_xlabel("t [min]")
|
|
self.ax.set_ylabel("Temp [°C]")
|
|
_style_axis(self.ax)
|
|
|
|
figure.tight_layout()
|
|
|
|
@staticmethod
|
|
def _estimate_course(schedule, start_theta):
|
|
"""Walks the schedule, accumulating (t, theta) waypoints: ramps take
|
|
abs(delta)/rate minutes at the declared rate, holds last for their
|
|
duration at the temperature the preceding ramp reached. A step may
|
|
carry both - ramp then hold - contributing both segments in turn."""
|
|
t = [0.0]
|
|
theta = [start_theta]
|
|
for step in schedule:
|
|
ramp = step.get('ramp')
|
|
hold = step.get('hold')
|
|
if ramp is not None:
|
|
rate = ramp.get('rate', 0)
|
|
target = step['temperature']
|
|
if rate > 0:
|
|
t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0)
|
|
theta.append(target)
|
|
if hold is not None:
|
|
t.append(t[-1] + hold.get('duration', 0) * 60.0)
|
|
theta.append(theta[-1])
|
|
return t, theta
|
|
|
|
def show_schedule(self, schedule, start_theta, name):
|
|
t, theta = self._estimate_course(schedule, start_theta)
|
|
t_min = [seconds / 60.0 for seconds in t]
|
|
|
|
self.line.set_data(t_min, theta)
|
|
self.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_no_schedule(self):
|
|
self.line.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()
|
|
|
|
|
|
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)
|
|
|
|
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
|
|
# 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
|
|
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)
|
|
# 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 a Sud ramp step that's cooling down
|
|
# (target below the current temperature - the heater is forced off
|
|
# server-side and we just wait for ambient loss to catch up).
|
|
self.sud_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.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})
|
|
|
|
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)
|
|
else:
|
|
self.forecast_plot.clear_progress()
|
|
|
|
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.ambient_temp = None
|
|
self.warp_factor = 1.0
|
|
self.sud_start_time = None
|
|
self.sud_paused_total = 0.0
|
|
self.sud_pause_started_at = None
|
|
self.set_sud_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']
|
|
elif "WarpFactor" in key:
|
|
self.warp_factor = msg['WarpFactor']
|
|
self.update_status_env_label()
|
|
|
|
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_sud_cooling(self, cooling):
|
|
if cooling == self.sud_cooling:
|
|
return
|
|
self.sud_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_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.
|
|
path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Sud", filter="JSON files (*.json)")
|
|
if not path:
|
|
return
|
|
self.sud_save_path = path
|
|
self.msg_sud.send({'Save': True})
|
|
|
|
def on_action_sud_load(self):
|
|
path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load Sud", filter="JSON files (*.json)")
|
|
if not path:
|
|
return
|
|
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'])
|
|
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)
|
|
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()
|
|
elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE:
|
|
self.sud_start_time = None
|
|
self.sud_paused_total = 0.0
|
|
self.sud_pause_started_at = None
|
|
|
|
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']
|
|
descr = step.get('Descr')
|
|
if descr:
|
|
self.statusBar().showMessage("Step {}: {}".format(step.get('Index'), descr))
|
|
else:
|
|
self.statusBar().clearMessage()
|
|
self.set_sud_cooling(step.get('Cooling', False))
|
|
self.update_sud_actions()
|
|
|
|
def update_sud_forecast(self, doc):
|
|
try:
|
|
name, _, schedule, _, _ = Sud._parse_data(doc)
|
|
except (KeyError, TypeError):
|
|
return
|
|
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.
|
|
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.")
|