thermostat/pytec/tec_qt.py

720 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem
import pyqtgraph as pg
from pglive.sources.data_connector import DataConnector
from pglive.kwargs import Axis
from pglive.sources.live_plot import LiveLinePlot
from pglive.sources.live_plot_widget import LivePlotWidget
from pglive.sources.live_axis import LiveAxis
import sys
import argparse
import logging
import asyncio
from pytec.aioclient import Client, StoppedConnecting
import qasync
from qasync import asyncSlot, asyncClose
# pyuic6 -x tec_qt.ui -o ui_tec_qt.py
from ui_tec_qt import Ui_MainWindow
class CommandsParameter(Parameter):
pass
THERMOSTAT_PARAMETERS = [[
{'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True,
'suffix': 'A', 'commands': [f'pwm {ch} i_set {{value}}']},
{'name': 'Temperature PID', 'type': 'bool', 'value': False, 'commands': [f'pwm {ch} pid'],
'children': [
{'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True,
'suffix': '°C', 'commands': [f'pid {ch} target {{value}}']},
]},
{'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, 'prefix': '±',
'suffix': 'A', 'commands': [f'pwm {ch} max_i_pos {{value}}', f'pwm {ch} max_i_neg {{value}}',
f'pid {ch} output_min -{{value}}', f'pid {ch} output_max {{value}}']},
{'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True,
'suffix': 'V', 'commands': [f'pwm {ch} max_v {{value}}']},
]},
{'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True,
'suffix': '°C', 'commands': [f's-h {ch} t0 {{value}}']},
{'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω',
'commands': [f's-h {ch} r0 {{value}}']},
{'name': 'β', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'commands': [f's-h {ch} b {{value}}']},
]},
{'name': 'Postfilter Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Rate', 'type': 'float', 'value': 16.67, 'step': 0.01, 'suffix': 'Hz',
'commands': [f'postfilter {ch} rate {{value}}']},
]},
{'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Kp', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°C/A', 'commands': [f'pid {ch} kp {{value}}']},
{'name': 'Ki', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°C/C', 'commands': [f'pid {ch} ki {{value}}']},
{'name': 'Kd', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°Cs²/C', 'commands': [f'pid {ch} kd {{value}}']},
{'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'siPrefix': True, 'suffix': '°C'},
{'name': 'Test Current', 'type': 'float', 'value': 1, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'},
{'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'prefix': '±', 'suffix': '°C'},
{'name': 'Run', 'type': 'action', 'tip': 'Run'},
]},
]},
{'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset', 'commands': [f'save {ch}']}
] for ch in range(2)]
def get_argparser():
parser = argparse.ArgumentParser(description="ARTIQ master")
parser.add_argument("--connect", default=None, action="store_true",
help="Automatically connect to the specified Thermostat in IP:port format")
parser.add_argument('IP', metavar="ip", default=None, nargs='?')
parser.add_argument('PORT', metavar="port", default=None, nargs='?')
parser.add_argument("-l", "--log", dest="logLevel", choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
help="Set the logging level")
return parser
class WrappedClient(QObject, Client):
connection_error = pyqtSignal()
def __init__(self, parent):
super().__init__(parent)
async def _read_line(self):
try:
return await super()._read_line()
except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11
logging.error("Client connection error, disconnecting", exc_info=True)
self.connection_error.emit()
async def _check_zero_limits(self):
pwm_report = await self.get_pwm()
for pwm_channel in pwm_report:
if (neg := pwm_channel["max_i_neg"]["value"]) != (pos := pwm_channel["max_i_pos"]["value"]):
# Set the minimum of the 2
lcd = min(neg, pos)
await self.set_param("pwm", pwm_channel["channel"], 'max_i_neg', lcd)
await self.set_param("pwm", pwm_channel["channel"], 'max_i_pos', lcd)
for limit in ["max_i_pos", "max_v"]:
if pwm_channel[limit]["value"] == 0.0:
QtWidgets.QMessageBox.warning(self.parent(), "Limits", "Max {} is set to zero on channel {}!".format("Current" if limit == "max_i_pos" else "Voltage", pwm_channel["channel"]))
class ClientWatcher(QObject):
fan_update = pyqtSignal(dict)
pwm_update = pyqtSignal(list)
report_update = pyqtSignal(list)
pid_update = pyqtSignal(list)
thermistor_update = pyqtSignal(list)
postfilter_update = pyqtSignal(list)
def __init__(self, parent, client, update_s):
self._update_s = update_s
self._client = client
self._watch_task = None
self._report_mode_task = None
self._poll_for_report = True
super().__init__(parent)
async def run(self):
loop = asyncio.get_running_loop()
while True:
time = loop.time()
await self.update_params()
await asyncio.sleep(self._update_s - (loop.time() - time))
async def update_params(self):
self.fan_update.emit(await self._client.get_fan())
self.pwm_update.emit(await self._client.get_pwm())
if self._poll_for_report:
self.report_update.emit(await self._client.report())
self.pid_update.emit(await self._client.get_pid())
self.thermistor_update.emit(await self._client.get_steinhart_hart())
self.postfilter_update.emit(await self._client.get_postfilter())
def start_watching(self):
self._watch_task = asyncio.create_task(self.run())
@pyqtSlot()
def stop_watching(self):
if self._watch_task is not None:
self._watch_task.cancel()
self._watch_task = None
async def set_report_mode(self, enabled: bool):
self._poll_for_report = not enabled
if enabled:
self._report_mode_task = asyncio.create_task(self.report_mode())
else:
self._client.stop_report_mode()
if self._report_mode_task is not None:
await self._report_mode_task
self._report_mode_task = None
async def report_mode(self):
async for report in self._client.report_mode():
self.report_update.emit(report)
@pyqtSlot(float)
def set_update_s(self, update_s):
self._update_s = update_s
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
"""The maximum number of sample points to store."""
DEFAULT_MAX_SAMPLES = 1000
def __init__(self, args):
super().__init__()
self.setupUi(self)
self.max_samples = self.DEFAULT_MAX_SAMPLES
self._set_up_connection_menu()
self._set_up_thermostat_menu()
self._set_up_plot_menu()
self.params = [
CommandsParameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=THERMOSTAT_PARAMETERS[ch])
for ch in range(2)
]
self._set_param_tree()
self.ch0_t_plot = LiveLinePlot()
self.ch0_i_plot = LiveLinePlot()
self.ch0_iset_plot = LiveLinePlot(pen=pg.mkPen('r'))
self.ch1_t_plot = LiveLinePlot()
self.ch1_i_plot = LiveLinePlot()
self.ch1_iset_plot = LiveLinePlot(pen=pg.mkPen('r'))
self.ch0_t_line = self.ch0_t_graph.getPlotItem().addLine(label='{value} °C')
self.ch0_t_line.setVisible(False)
self.ch1_t_line = self.ch1_t_graph.getPlotItem().addLine(label='{value} °C')
self.ch1_t_line.setVisible(False)
self._set_up_graphs()
self.ch0_t_connector = DataConnector(self.ch0_t_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.ch0_i_connector = DataConnector(self.ch0_i_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.ch0_iset_connector = DataConnector(self.ch0_iset_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.ch1_t_connector = DataConnector(self.ch1_t_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.ch1_i_connector = DataConnector(self.ch1_i_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.ch1_iset_connector = DataConnector(self.ch1_iset_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.hw_rev_data = None
self.client = WrappedClient(self)
self.client.connection_error.connect(self.bail)
self.client_watcher = ClientWatcher(self, self.client, self.report_refresh_spin.value())
self.client_watcher.fan_update.connect(self.fan_update)
self.client_watcher.report_update.connect(self.plot)
self.client_watcher.report_update.connect(self.update_report)
self.client_watcher.pid_update.connect(self.update_pid)
self.client_watcher.pwm_update.connect(self.update_pwm)
self.client_watcher.thermistor_update.connect(self.update_thermistor)
self.client_watcher.postfilter_update.connect(self.update_postfilter)
self.report_apply_btn.clicked.connect(
lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value())
)
if args.connect:
if args.IP:
self.host_set_line.setText(args.IP)
if args.PORT:
self.port_set_spin.setValue(int(args.PORT))
self.connect_btn.click()
def _set_up_connection_menu(self):
_translate = QtCore.QCoreApplication.translate
self.connection_menu = QtWidgets.QMenu()
self.connection_menu.setTitle('Connection Settings')
self.host_set_line = QtWidgets.QLineEdit()
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.host_set_line.sizePolicy().hasHeightForWidth())
self.host_set_line.setSizePolicy(sizePolicy)
self.host_set_line.setMinimumSize(QtCore.QSize(160, 0))
self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215))
self.host_set_line.setMaxLength(15)
self.host_set_line.setClearButtonEnabled(True)
self.host_set_line.setText("192.168.1.26")
self.host_set_line.setPlaceholderText(_translate("MainWindow", "IP for the Thermostat"))
host = QtWidgets.QWidgetAction(self.connection_menu)
host.setDefaultWidget(self.host_set_line)
self.connection_menu.addAction(host)
self.connection_menu.host = host
self.port_set_spin = QtWidgets.QSpinBox()
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.port_set_spin.sizePolicy().hasHeightForWidth())
self.port_set_spin.setSizePolicy(sizePolicy)
self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0))
self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215))
self.port_set_spin.setMaximum(65535)
self.port_set_spin.setValue(23)
port = QtWidgets.QWidgetAction(self.connection_menu)
port.setDefaultWidget(self.port_set_spin)
self.connection_menu.addAction(port)
self.connection_menu.port = port
self.connect_btn.setMenu(self.connection_menu)
def _set_up_thermostat_menu(self):
_translate = QtCore.QCoreApplication.translate
self.thermostat_menu = QtWidgets.QMenu()
self.thermostat_menu.setTitle('Thermostat settings')
self.fan_group = QtWidgets.QWidget()
self.fan_group.setEnabled(False)
self.fan_group.setMinimumSize(QtCore.QSize(40, 0))
self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group)
self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout_6.setSpacing(0)
self.gan_layout = QtWidgets.QHBoxLayout()
self.gan_layout.setSpacing(9)
self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.fan_lbl.sizePolicy().hasHeightForWidth())
self.fan_lbl.setSizePolicy(sizePolicy)
self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0))
self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215))
self.fan_lbl.setBaseSize(QtCore.QSize(40, 0))
self.gan_layout.addWidget(self.fan_lbl)
self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.fan_power_slider.sizePolicy().hasHeightForWidth())
self.fan_power_slider.setSizePolicy(sizePolicy)
self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0))
self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215))
self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0))
self.fan_power_slider.setMinimum(1)
self.fan_power_slider.setMaximum(100)
self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.gan_layout.addWidget(self.fan_power_slider)
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.fan_auto_box.sizePolicy().hasHeightForWidth())
self.fan_auto_box.setSizePolicy(sizePolicy)
self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0))
self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215))
self.gan_layout.addWidget(self.fan_auto_box)
self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
self.fan_pwm_warning.setText("")
self.gan_layout.addWidget(self.fan_pwm_warning)
self.horizontalLayout_6.addLayout(self.gan_layout)
self.fan_power_slider.valueChanged.connect(self.fan_set)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set)
self.fan_lbl.setToolTip(_translate("MainWindow", "Adjust the fan"))
self.fan_lbl.setText(_translate("MainWindow", "Fan:"))
self.fan_auto_box.setText(_translate("MainWindow", "Auto"))
fan = QtWidgets.QWidgetAction(self.thermostat_menu)
fan.setDefaultWidget(self.fan_group)
self.thermostat_menu.addAction(fan)
self.thermostat_menu.fan = fan
@asyncSlot(bool)
async def reset_thermostat(_):
await self._on_connection_changed(False)
await self.client.reset()
await asyncio.sleep(0.1) # Wait for the reset to start
self.connect_btn.click() # Reconnect
self.actionReset.triggered.connect(reset_thermostat)
self.thermostat_menu.addAction(self.actionReset)
@asyncSlot(bool)
async def dfu_mode(_):
await self._on_connection_changed(False)
await self.client.dfu()
# TODO: add a firmware flashing GUI?
self.actionEnter_DFU_Mode.triggered.connect(dfu_mode)
self.thermostat_menu.addAction(self.actionEnter_DFU_Mode)
@asyncSlot(bool)
async def network_settings(_):
ask_network = QtWidgets.QInputDialog(self)
ask_network.setWindowTitle(_translate("MainWindow", "Network Settings"))
ask_network.setLabelText(_translate("MainWindow", "Set the Thermostat's IPv4 address, netmask and gateway (optional)"))
ask_network.setTextValue((await self.client.ipv4())['addr'])
@pyqtSlot(str)
def set_ipv4(ipv4_settings):
sure = QtWidgets.QMessageBox(self)
sure.setWindowTitle(_translate("MainWindow", "Set network?"))
sure.setText(f"Setting this as network and disconnecting:<br>{ipv4_settings}")
@asyncSlot(object)
async def really_set(button):
await self.client.set_param("ipv4", ipv4_settings)
await self.client.disconnect()
await self._on_connection_changed(False)
sure.buttonClicked.connect(really_set)
sure.show()
ask_network.textValueSelected.connect(set_ipv4)
ask_network.show()
self.actionNetwork_Settings.triggered.connect(network_settings)
self.thermostat_menu.addAction(self.actionNetwork_Settings)
@asyncSlot(bool)
async def load(_):
await self.client.load_config()
self.actionLoad_all_configs.triggered.connect(load)
self.thermostat_menu.addAction(self.actionLoad_all_configs)
@asyncSlot(bool)
async def save(_):
await self.client.save_config()
self.actionSave_all_configs.triggered.connect(save)
self.thermostat_menu.addAction(self.actionSave_all_configs)
def about_thermostat():
QtWidgets.QMessageBox.about(
self,
_translate("MainWindow","About Thermostat"),
f"""
<h1>Sinara 8451 Thermostat v{self.hw_rev_d['rev']['major']}.{self.hw_rev_d['rev']['minor']}</h1>
<br>
<h2>Settings:</h2>
Default fan curve:
a = {self.hw_rev_d['settings']['fan_k_a']},
b = {self.hw_rev_d['settings']['fan_k_b']},
c = {self.hw_rev_d['settings']['fan_k_c']}
<br>
Fan PWM range:
{self.hw_rev_d['settings']['min_fan_pwm']} {self.hw_rev_d['settings']['max_fan_pwm']}
<br>
Fan PWM frequency: {self.hw_rev_d['settings']['fan_pwm_freq_hz']} Hz
<br>
Fan available: {self.hw_rev_d['settings']['fan_available']}
<br>
Fan PWM recommended: {self.hw_rev_d['settings']['fan_pwm_recommended']}
"""
)
self.actionAbout_Thermostat.triggered.connect(about_thermostat)
self.thermostat_menu.addAction(self.actionAbout_Thermostat)
self.thermostat_settings.setMenu(self.thermostat_menu)
def _set_up_plot_menu(self):
_translate = QtCore.QCoreApplication.translate
self.plot_menu = QtWidgets.QMenu()
self.plot_menu.setTitle("Plot Settings")
clear = QtGui.QAction("Clear graphs", self.plot_menu)
clear.triggered.connect(self.clear_graphs)
self.plot_menu.addAction(clear)
self.plot_menu.clear = clear
self.samples_spinbox = QtWidgets.QSpinBox()
self.samples_spinbox.setRange(2, 100000)
self.samples_spinbox.setSuffix(' samples')
self.samples_spinbox.setValue(self.max_samples)
self.samples_spinbox.valueChanged.connect(self.set_max_samples)
limit_samples = QtWidgets.QWidgetAction(self.plot_menu)
limit_samples.setDefaultWidget(self.samples_spinbox)
self.plot_menu.addAction(limit_samples)
self.plot_menu.limit_samples = limit_samples
self.plot_settings.setMenu(self.plot_menu)
@pyqtSlot(int)
def set_max_samples(self, samples: int):
self.ch0_t_connector.max_points = samples
self.ch0_i_connector.max_points = samples
self.ch0_iset_connector.max_points = samples
self.ch1_t_connector.max_points = samples
self.ch1_i_connector.max_points = samples
self.ch1_iset_connector.max_points = samples
def _set_up_graphs(self):
for graph in self.ch0_t_graph, self.ch0_i_graph, self.ch1_t_graph, self.ch1_i_graph:
time_axis = LiveAxis('bottom', text="Time since Thermostat reset", **{Axis.TICK_FORMAT: Axis.DURATION})
time_axis.showLabel()
graph.setAxisItems({'bottom': time_axis})
graph.add_crosshair(pg.mkPen(color='red', width=1), {'color': 'green'})
# Enable linking of axes in the graph widget's context menu
graph.register(graph.getPlotItem().titleLabel.text) # Slight hack getting the title
for graph in self.ch0_t_graph, self.ch1_t_graph:
temperature_axis = LiveAxis('left', text="Temperature", units="°C")
temperature_axis.showLabel()
graph.setAxisItems({'left': temperature_axis})
for graph in self.ch0_i_graph, self.ch1_i_graph:
current_axis = LiveAxis('left', text="Current", units="A")
current_axis.showLabel()
graph.setAxisItems({'left': current_axis})
self.ch0_t_graph.addItem(self.ch0_t_plot)
self.ch0_i_graph.addItem(self.ch0_i_plot)
self.ch0_i_graph.addItem(self.ch0_iset_plot)
self.ch1_t_graph.addItem(self.ch1_t_plot)
self.ch1_i_graph.addItem(self.ch1_i_plot)
self.ch1_i_graph.addItem(self.ch1_iset_plot)
def clear_graphs(self):
for connector in self.ch0_t_connector, self.ch0_i_connector, self.ch0_iset_connector, self.ch1_t_connector, self.ch1_i_connector, self.ch1_iset_connector:
connector.clear()
async def _on_connection_changed(self, result):
self.graph_group.setEnabled(result)
self.report_group.setEnabled(result)
self.thermostat_settings.setEnabled(result)
self.host_set_line.setEnabled(not result)
self.port_set_spin.setEnabled(not result)
self.connect_btn.setText("Disconnect" if result else "Connect")
if result:
self.hw_rev_data = await self.client.hw_rev()
self._status(self.hw_rev_data)
self.client_watcher.start_watching()
# await self.client.set_param("fan", 1)
else:
self.status_lbl.setText("Disconnected")
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.fan_pwm_warning.setToolTip("")
self.clear_graphs()
self.report_box.setChecked(False)
await self.client_watcher.set_report_mode(False)
self.client_watcher.stop_watching()
self.status_lbl.setText("Disconnected")
def _set_fan_pwm_warning(self):
if self.fan_power_slider.value() != 100:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
icon = self.style().standardIcon(pixmapi)
self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
self.fan_pwm_warning.setToolTip("Throttling the fan (not recommended on this hardware rev)")
else:
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.fan_pwm_warning.setToolTip("")
def _status(self, hw_rev_d: dict):
logging.debug(hw_rev_d)
self.status_lbl.setText(f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}")
self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"])
@pyqtSlot(dict)
def fan_update(self, fan_settings: dict):
logging.debug(fan_settings)
if fan_settings is None:
return
with QSignalBlocker(self.fan_power_slider):
self.fan_power_slider.setValue(fan_settings["fan_pwm"] or 100) # 0 = PWM off = full strength
with QSignalBlocker(self.fan_auto_box):
self.fan_auto_box.setChecked(fan_settings["auto_mode"])
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self._set_fan_pwm_warning()
@asyncSlot(int)
async def fan_set(self, value):
if not self.client.connected():
return
if self.fan_auto_box.isChecked():
with QSignalBlocker(self.fan_auto_box):
self.fan_auto_box.setChecked(False)
await self.client.set_fan(value)
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self._set_fan_pwm_warning()
@asyncSlot(int)
async def fan_auto_set(self, enabled):
if not self.client.connected():
return
if enabled:
await self.client.set_fan("auto")
self.fan_update(await self.client.get_fan())
else:
await self.client.set_fan(self.fan_power_slider.value())
@asyncSlot(int)
async def on_report_box_stateChanged(self, enabled):
await self.client_watcher.set_report_mode(enabled)
@asyncClose
async def closeEvent(self, event):
await self.bail()
@asyncSlot()
async def on_connect_btn_clicked(self):
host, port = self.host_set_line.text(), self.port_set_spin.value()
try:
if not (self.client.connecting() or self.client.connected()):
self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop")
self.host_set_line.setEnabled(False)
self.port_set_spin.setEnabled(False)
try:
await self.client.start_session(host=host, port=port, timeout=30)
except StoppedConnecting:
return
await self._on_connection_changed(True)
else:
await self.bail()
except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11
logging.error(f"Failed communicating to {host}:{port}: {e}")
await self.bail()
@asyncSlot()
async def bail(self):
await self._on_connection_changed(False)
await self.client.end_session()
@pyqtSlot(list)
def plot(self, report):
for channel in range(2):
temperature = report[channel]['temperature']
current = report[channel]['tec_i']
iset = report[channel]['i_set']
time = report[channel]['time']
if temperature is not None and current is not None and iset is not None:
getattr(self, f'ch{channel}_t_connector').cb_append_data_point(temperature, time)
getattr(self, f'ch{channel}_i_connector').cb_append_data_point(current, time)
getattr(self, f'ch{channel}_iset_connector').cb_append_data_point(iset, time)
@asyncSlot(object, object)
async def send_command(self, param, changes):
for inner_param, change, data in changes:
if inner_param.opts.get("commands", None) is not None:
ch = param.value()
match inner_param.name():
case 'Temperature PID':
pid_enabled = data
getattr(self, f'ch{ch}_t_line').setVisible(pid_enabled)
if pid_enabled:
getattr(self, f'ch{ch}_t_line').setValue(inner_param.child('Set Temperature').value())
else:
await self.client.set_param('pwm', ch, 'i_set', param.child('Constant Current').value())
return
case 'Set Temperature':
getattr(self, f'ch{ch}_t_line').setValue(data)
await asyncio.gather(*[self.client._command(x.format(value=data)) for x in inner_param.opts["commands"]])
def _set_param_tree(self):
for i, tree in enumerate((self.ch0_tree, self.ch1_tree)):
tree.setHeaderHidden(True)
tree.setParameters(self.params[i], showTop=False)
self.params[i].sigTreeStateChanged.connect(self.send_command)
@pyqtSlot(list)
def update_pid(self, pid_settings):
for settings in pid_settings:
channel = settings["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("PID Config", "Kp").setValue(settings["parameters"]["kp"])
self.params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"])
self.params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"])
if self.params[channel].child("Temperature PID").value():
self.params[channel].child("Temperature PID", "Set Temperature").setValue(settings["target"])
getattr(self, f'ch{channel}_t_line').setValue(settings["target"])
@pyqtSlot(list)
def update_report(self, report_data):
for settings in report_data:
channel = settings["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Temperature PID").setValue(settings["pid_engaged"])
getattr(self, f'ch{channel}_t_line').setVisible(settings["pid_engaged"])
if not settings["pid_engaged"]:
self.params[channel].child("Constant Current").setValue(settings["i_set"])
@pyqtSlot(list)
def update_thermistor(self, sh_data):
for sh_param in sh_data:
channel = sh_param["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Thermistor Config", "T₀").setValue(sh_param["params"]["t0"] - 273.15)
self.params[channel].child("Thermistor Config", "R₀").setValue(sh_param["params"]["r0"])
self.params[channel].child("Thermistor Config", "β").setValue(sh_param["params"]["b"])
@pyqtSlot(list)
def update_pwm(self, pwm_data):
for pwm_params in pwm_data:
channel = pwm_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Output Config", "Max Voltage").setValue(pwm_params["max_v"]["value"])
self.params[channel].child("Output Config", "Max Current").setValue(pwm_params["max_i_pos"]["value"])
@pyqtSlot(list)
def update_postfilter(self, postfilter_data):
for postfilter_params in postfilter_data:
channel = postfilter_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Postfilter Config", "Rate").setValue(postfilter_params["rate"])
def retranslateUi(self, MainWindow):
super().retranslateUi(MainWindow)
_translate = QtCore.QCoreApplication.translate
self.ch0_t_graph.setTitle(_translate("MainWindow", "Channel 0 Temperature"))
self.ch0_i_graph.setTitle(_translate("MainWindow", "Channel 0 Current"))
self.ch1_t_graph.setTitle(_translate("MainWindow", "Channel 1 Temperature"))
self.ch1_i_graph.setTitle(_translate("MainWindow", "Channel 1 Current"))
async def coro_main():
args = get_argparser().parse_args()
if args.logLevel:
logging.basicConfig(level=getattr(logging, args.logLevel))
app_quit_event = asyncio.Event()
app = QtWidgets.QApplication.instance()
app.aboutToQuit.connect(app_quit_event.set)
main_window = MainWindow(args)
main_window.show()
await app_quit_event.wait()
def main():
qasync.run(coro_main())
if __name__ == '__main__':
main()