From 446c3ea1d6b2d597ef6f6484da4fb7278085a724 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Wed, 1 Jun 2022 12:32:18 +0800 Subject: [PATCH 001/247] add pyqtgraph --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 803e774..e943c36 100644 --- a/flake.nix +++ b/flake.nix @@ -69,7 +69,7 @@ buildInputs = with pkgs; [ rust openocd dfu-util ] ++ (with python3Packages; [ - numpy matplotlib + numpy matplotlib pyqtgraph ]); }; defaultPackage.x86_64-linux = thermostat; From 8e45e98ee00e909b1a743ef9ad86ff49951b371a Mon Sep 17 00:00:00 2001 From: topquark12 Date: Wed, 1 Jun 2022 13:09:01 +0800 Subject: [PATCH 002/247] fix pyqtgraph on nixos --- flake.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flake.nix b/flake.nix index e943c36..76a885d 100644 --- a/flake.nix +++ b/flake.nix @@ -71,6 +71,11 @@ ] ++ (with python3Packages; [ numpy matplotlib pyqtgraph ]); + shellHook= + '' + export QT_PLUGIN_PATH=${pkgs.qt5.qtbase}/${pkgs.qt5.qtbase.dev.qtPluginPrefix} + export QML2_IMPORT_PATH=${pkgs.qt5.qtbase}/${pkgs.qt5.qtbase.dev.qtQmlPrefix} + ''; }; defaultPackage.x86_64-linux = thermostat; }; From 6655581b6fc905c10e83018d5c8f72ae48075b10 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Wed, 1 Jun 2022 17:47:31 +0800 Subject: [PATCH 003/247] plot both channel temperatures --- pytec/tecQT.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 pytec/tecQT.py diff --git a/pytec/tecQT.py b/pytec/tecQT.py new file mode 100644 index 0000000..cf33439 --- /dev/null +++ b/pytec/tecQT.py @@ -0,0 +1,86 @@ +from pyqtgraph.Qt import QtGui, QtCore +import numpy as np +import pyqtgraph as pg +from pytec.client import Client + +rec_len = 1000 +refresh_period = 20 + +channel_data = [{ + 'adc': np.zeros(rec_len), + 'sens': np.zeros(rec_len), + 'temperature': np.zeros(rec_len), + 'i_set': np.zeros(rec_len), + 'pid_output': np.zeros(rec_len), + 'vref': np.zeros(rec_len), + 'dac_value': np.zeros(rec_len), + 'dac_feedback': np.zeros(rec_len), + 'i_tec': np.zeros(rec_len), + 'tec_i': np.zeros(rec_len), + 'tec_u_meas': np.zeros(rec_len), + 'interval': np.zeros(rec_len), + 'temp_set': np.zeros(rec_len), +} for _ in range(2)] + +tec = Client() + +app = pg.mkQApp() +mw = QtGui.QMainWindow() +mw.setWindowTitle('Thermostat Control Panel') +mw.resize(800,800) +cw = QtGui.QWidget() +mw.setCentralWidget(cw) +l = QtGui.QVBoxLayout() +cw.setLayout(l) + +pg.setConfigOptions(antialias=True) + +pw0= pg.PlotWidget(name='Channel 0') +l.addWidget(pw0) +pw1 = pg.PlotWidget(name='Channel 1') +l.addWidget(pw1) + +curve0 = pw0.plot() +curve1 = pw1.plot() + +cnt = 0 +time_stamp = np.zeros(rec_len) +def update(n): + global cnt + for data in tec.report_mode(): + ch = data[n] + for tag, seq in channel_data[n].items(): + if tag in ch: + v = ch[tag] + if type(v) is float: + if cnt == 0: + np.copyto(seq, np.full(rec_len, v)) + else: + seq[:-1] = seq[1:] + seq[-1] = v + if quit: + break + return + +def updateData(): + global cnt + update(0) + update(1) + cnt += 1 + time_stamp[:-1] = time_stamp[1:] + time_stamp[-1] = cnt * refresh_period / 1000 + pw0.setRange(xRange=[cnt * refresh_period / 1000 - 20.0, cnt * refresh_period / 1000]) + pw1.setRange(xRange=[cnt * refresh_period / 1000 - 20.0, cnt * refresh_period / 1000]) + curve0.setData(x = time_stamp, y = channel_data[0]['temperature']) + curve1.setData(x = time_stamp, y = channel_data[1]['temperature']) + + +## Start a timer to rapidly update the plot in pw +t = QtCore.QTimer() +t.timeout.connect(updateData) +t.start(refresh_period) + +mw.show() + +if __name__ == '__main__': + pg.exec() \ No newline at end of file From fe28ac98e5151f99c4262187986c48b9bbf61f22 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Thu, 2 Jun 2022 20:08:18 +0800 Subject: [PATCH 004/247] add more graphs in 2x2 grid --- pytec/tecQT.py | 47 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index cf33439..aa12fb3 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -19,7 +19,6 @@ channel_data = [{ 'tec_i': np.zeros(rec_len), 'tec_u_meas': np.zeros(rec_len), 'interval': np.zeros(rec_len), - 'temp_set': np.zeros(rec_len), } for _ in range(2)] tec = Client() @@ -31,22 +30,38 @@ mw.resize(800,800) cw = QtGui.QWidget() mw.setCentralWidget(cw) l = QtGui.QVBoxLayout() +layout = pg.LayoutWidget() +l.addWidget(layout) cw.setLayout(l) pg.setConfigOptions(antialias=True) -pw0= pg.PlotWidget(name='Channel 0') -l.addWidget(pw0) -pw1 = pg.PlotWidget(name='Channel 1') -l.addWidget(pw1) +temp0plot= pg.PlotWidget(title='Channel 0 Temperature') +layout.addWidget(temp0plot, 1, 1) +temp1plot = pg.PlotWidget(title='Channel 1 Temperature') +layout.addWidget(temp1plot, 2, 1) +current0plot = pg.PlotWidget(title='Channel 0 Current') +layout.addWidget(current0plot, 1, 2) +current1plot = pg.PlotWidget(title='Channel 1 Current') +layout.addWidget(current1plot, 2, 2) + +temp0curve = pg.PlotCurveItem(pen=({'color': 'r', 'width': 1})) +temp1curve = pg.PlotCurveItem(pen=({'color': 'r', 'width': 1})) +tecI0curve = pg.PlotCurveItem(pen=({'color': 'r', 'width': 1})) +tecI1curve = pg.PlotCurveItem(pen=({'color': 'r', 'width': 1})) +Iset0curve = pg.PlotCurveItem(pen=({'color': 'g', 'width': 1})) +Iset1curve = pg.PlotCurveItem(pen=({'color': 'g', 'width': 1})) +temp0plot.addItem(temp0curve) +temp1plot.addItem(temp1curve) +current0plot.addItem(tecI0curve) +current0plot.addItem(Iset0curve) +current1plot.addItem(tecI1curve) +current1plot.addItem(Iset1curve) -curve0 = pw0.plot() -curve1 = pw1.plot() cnt = 0 time_stamp = np.zeros(rec_len) def update(n): - global cnt for data in tec.report_mode(): ch = data[n] for tag, seq in channel_data[n].items(): @@ -56,7 +71,7 @@ def update(n): if cnt == 0: np.copyto(seq, np.full(rec_len, v)) else: - seq[:-1] = seq[1:] + seq[:-1] = seq[1:] seq[-1] = v if quit: break @@ -69,10 +84,16 @@ def updateData(): cnt += 1 time_stamp[:-1] = time_stamp[1:] time_stamp[-1] = cnt * refresh_period / 1000 - pw0.setRange(xRange=[cnt * refresh_period / 1000 - 20.0, cnt * refresh_period / 1000]) - pw1.setRange(xRange=[cnt * refresh_period / 1000 - 20.0, cnt * refresh_period / 1000]) - curve0.setData(x = time_stamp, y = channel_data[0]['temperature']) - curve1.setData(x = time_stamp, y = channel_data[1]['temperature']) + temp0plot.setRange(xRange=[(cnt - rec_len) * refresh_period / 1000, cnt * refresh_period / 1000]) + temp1plot.setRange(xRange=[(cnt - rec_len) * refresh_period / 1000, cnt * refresh_period / 1000]) + current0plot.setRange(xRange=[(cnt - rec_len) * refresh_period / 1000, cnt * refresh_period / 1000]) + current1plot.setRange(xRange=[(cnt - rec_len) * refresh_period / 1000, cnt * refresh_period / 1000]) + temp0curve.setData(x = time_stamp, y = channel_data[0]['temperature']) + temp1curve.setData(x = time_stamp, y = channel_data[1]['temperature']) + tecI0curve.setData(x = time_stamp, y = channel_data[0]['tec_i']) + tecI1curve.setData(x = time_stamp, y = channel_data[1]['tec_i']) + Iset0curve.setData(x = time_stamp, y = channel_data[0]['i_set']) + Iset1curve.setData(x = time_stamp, y = channel_data[1]['i_set']) ## Start a timer to rapidly update the plot in pw From 61a8af468a6535b72b6b00945beb1321ea3b6f82 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Sun, 5 Jun 2022 14:58:42 +0800 Subject: [PATCH 005/247] add graph legends --- pytec/tecQT.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index aa12fb3..2070392 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -26,7 +26,7 @@ tec = Client() app = pg.mkQApp() mw = QtGui.QMainWindow() mw.setWindowTitle('Thermostat Control Panel') -mw.resize(800,800) +mw.resize(1024,800) cw = QtGui.QWidget() mw.setCentralWidget(cw) l = QtGui.QVBoxLayout() @@ -58,6 +58,16 @@ current0plot.addItem(Iset0curve) current1plot.addItem(tecI1curve) current1plot.addItem(Iset1curve) +temp0legend = temp0plot.getPlotItem().addLegend(brush=(50,50,200,150)) +temp0legend.addItem(temp0curve, 'feedback') +temp1legend = temp1plot.getPlotItem().addLegend(brush=(50,50,200,150)) +temp1legend.addItem(temp0curve, 'feedback') +current0legend = current0plot.getPlotItem().addLegend(brush=(50,50,200,150)) +current0legend.addItem(tecI0curve, 'feedback') +current0legend.addItem(Iset0curve, 'setpoint') +current1legend = current1plot.getPlotItem().addLegend(brush=(50,50,200,150)) +current1legend.addItem(tecI1curve, 'feedback') +current1legend.addItem(Iset1curve, 'setpoint') cnt = 0 time_stamp = np.zeros(rec_len) From 4310a270857db7afd441e31c542c7403e92f3ff3 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Sun, 5 Jun 2022 16:03:33 +0800 Subject: [PATCH 006/247] refactor with classes --- pytec/tecQT.py | 131 ++++++++++++++++++++----------------------------- 1 file changed, 53 insertions(+), 78 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 2070392..1ed6eaf 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -6,20 +6,43 @@ from pytec.client import Client rec_len = 1000 refresh_period = 20 -channel_data = [{ - 'adc': np.zeros(rec_len), - 'sens': np.zeros(rec_len), - 'temperature': np.zeros(rec_len), - 'i_set': np.zeros(rec_len), - 'pid_output': np.zeros(rec_len), - 'vref': np.zeros(rec_len), - 'dac_value': np.zeros(rec_len), - 'dac_feedback': np.zeros(rec_len), - 'i_tec': np.zeros(rec_len), - 'tec_i': np.zeros(rec_len), - 'tec_u_meas': np.zeros(rec_len), - 'interval': np.zeros(rec_len), -} for _ in range(2)] +class Curves: + def __init__(self, legend: str, key: str, channel: int, color: str, buffer_len: int, period: int): + self.curveItem = pg.PlotCurveItem(pen=({'color': color, 'width': 1})) + self.legendStr = legend + self.keyStr = key + self.channel = channel + self.data_buf = np.zeros(buffer_len) + self.time_stamp = np.zeros(buffer_len) + self.buffLen = buffer_len + self.period = period + + def update(self, tec_data, cnt): + if cnt == 0: + np.copyto(self.data_buf, np.full(self.buffLen, tec_data[self.channel][self.keyStr])) + else: + self.data_buf[:-1] = self.data_buf[1:] + self.data_buf[-1] = tec_data[self.channel][self.keyStr] + self.time_stamp[:-1] = self.time_stamp[1:] + self.time_stamp[-1] = cnt * self.period / 1000 + self.curveItem.setData(x = self.time_stamp, y = self.data_buf) + + +class Graph: + def __init__(self, parent: pg.LayoutWidget, title: str, row: int, col: int, curves: list[Curves]): + self.plotItem = pg.PlotWidget(title=title) + self.legendItem = pg.LegendItem(offset=(75, 30), brush=(50,50,200,150)) + self.legendItem.setParentItem(self.plotItem.getPlotItem()) + parent.addWidget(self.plotItem, row, col) + self.curves = curves + for curve in self.curves: + self.plotItem.addItem(curve.curveItem) + self.legendItem.addItem(curve.curveItem, curve.legendStr) + + def update(self, tec_data, cnt): + for curve in self.curves: + curve.update(tec_data, cnt) + self.plotItem.setRange(xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000]) tec = Client() @@ -36,74 +59,26 @@ cw.setLayout(l) pg.setConfigOptions(antialias=True) -temp0plot= pg.PlotWidget(title='Channel 0 Temperature') -layout.addWidget(temp0plot, 1, 1) -temp1plot = pg.PlotWidget(title='Channel 1 Temperature') -layout.addWidget(temp1plot, 2, 1) -current0plot = pg.PlotWidget(title='Channel 0 Current') -layout.addWidget(current0plot, 1, 2) -current1plot = pg.PlotWidget(title='Channel 1 Current') -layout.addWidget(current1plot, 2, 2) - -temp0curve = pg.PlotCurveItem(pen=({'color': 'r', 'width': 1})) -temp1curve = pg.PlotCurveItem(pen=({'color': 'r', 'width': 1})) -tecI0curve = pg.PlotCurveItem(pen=({'color': 'r', 'width': 1})) -tecI1curve = pg.PlotCurveItem(pen=({'color': 'r', 'width': 1})) -Iset0curve = pg.PlotCurveItem(pen=({'color': 'g', 'width': 1})) -Iset1curve = pg.PlotCurveItem(pen=({'color': 'g', 'width': 1})) -temp0plot.addItem(temp0curve) -temp1plot.addItem(temp1curve) -current0plot.addItem(tecI0curve) -current0plot.addItem(Iset0curve) -current1plot.addItem(tecI1curve) -current1plot.addItem(Iset1curve) - -temp0legend = temp0plot.getPlotItem().addLegend(brush=(50,50,200,150)) -temp0legend.addItem(temp0curve, 'feedback') -temp1legend = temp1plot.getPlotItem().addLegend(brush=(50,50,200,150)) -temp1legend.addItem(temp0curve, 'feedback') -current0legend = current0plot.getPlotItem().addLegend(brush=(50,50,200,150)) -current0legend.addItem(tecI0curve, 'feedback') -current0legend.addItem(Iset0curve, 'setpoint') -current1legend = current1plot.getPlotItem().addLegend(brush=(50,50,200,150)) -current1legend.addItem(tecI1curve, 'feedback') -current1legend.addItem(Iset1curve, 'setpoint') +ch0tempGraph = Graph(layout, 'Channel 0 Termperature', 1, 1, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) +ch1tempGraph = Graph(layout, 'Channel 1 Termperature', 2, 1, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) +ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 2, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), + Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) +ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 2, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), + Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)]) cnt = 0 -time_stamp = np.zeros(rec_len) -def update(n): - for data in tec.report_mode(): - ch = data[n] - for tag, seq in channel_data[n].items(): - if tag in ch: - v = ch[tag] - if type(v) is float: - if cnt == 0: - np.copyto(seq, np.full(rec_len, v)) - else: - seq[:-1] = seq[1:] - seq[-1] = v - if quit: - break - return - def updateData(): global cnt - update(0) - update(1) - cnt += 1 - time_stamp[:-1] = time_stamp[1:] - time_stamp[-1] = cnt * refresh_period / 1000 - temp0plot.setRange(xRange=[(cnt - rec_len) * refresh_period / 1000, cnt * refresh_period / 1000]) - temp1plot.setRange(xRange=[(cnt - rec_len) * refresh_period / 1000, cnt * refresh_period / 1000]) - current0plot.setRange(xRange=[(cnt - rec_len) * refresh_period / 1000, cnt * refresh_period / 1000]) - current1plot.setRange(xRange=[(cnt - rec_len) * refresh_period / 1000, cnt * refresh_period / 1000]) - temp0curve.setData(x = time_stamp, y = channel_data[0]['temperature']) - temp1curve.setData(x = time_stamp, y = channel_data[1]['temperature']) - tecI0curve.setData(x = time_stamp, y = channel_data[0]['tec_i']) - tecI1curve.setData(x = time_stamp, y = channel_data[1]['tec_i']) - Iset0curve.setData(x = time_stamp, y = channel_data[0]['i_set']) - Iset1curve.setData(x = time_stamp, y = channel_data[1]['i_set']) + for data in tec.report_mode(): + + ch0tempGraph.update(data, cnt) + ch1tempGraph.update(data, cnt) + ch0currentGraph.update(data, cnt) + ch1currentGraph.update(data, cnt) + + if quit: + break + cnt += 1 ## Start a timer to rapidly update the plot in pw From d6a80c4f9b4a0870b0a523a23a4538658e95b8c8 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Sun, 5 Jun 2022 16:04:46 +0800 Subject: [PATCH 007/247] fix typo --- pytec/tecQT.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 1ed6eaf..df8f403 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -59,8 +59,8 @@ cw.setLayout(l) pg.setConfigOptions(antialias=True) -ch0tempGraph = Graph(layout, 'Channel 0 Termperature', 1, 1, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) -ch1tempGraph = Graph(layout, 'Channel 1 Termperature', 2, 1, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) +ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 1, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) +ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 1, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 2, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 2, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), From d841cd25597bf6e01da7f6628f03746c1a7b7632 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Sun, 5 Jun 2022 17:21:50 +0800 Subject: [PATCH 008/247] add voltage monitoring --- pytec/tecQT.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index df8f403..7bce9f0 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -3,6 +3,8 @@ import numpy as np import pyqtgraph as pg from pytec.client import Client +tec = Client(host="192.168.1.26", port=23, timeout=None) + rec_len = 1000 refresh_period = 20 @@ -27,7 +29,6 @@ class Curves: self.time_stamp[-1] = cnt * self.period / 1000 self.curveItem.setData(x = self.time_stamp, y = self.data_buf) - class Graph: def __init__(self, parent: pg.LayoutWidget, title: str, row: int, col: int, curves: list[Curves]): self.plotItem = pg.PlotWidget(title=title) @@ -44,12 +45,10 @@ class Graph: curve.update(tec_data, cnt) self.plotItem.setRange(xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000]) -tec = Client() - app = pg.mkQApp() mw = QtGui.QMainWindow() mw.setWindowTitle('Thermostat Control Panel') -mw.resize(1024,800) +mw.resize(1500,800) cw = QtGui.QWidget() mw.setCentralWidget(cw) l = QtGui.QVBoxLayout() @@ -59,12 +58,14 @@ cw.setLayout(l) pg.setConfigOptions(antialias=True) -ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 1, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) -ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 1, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) -ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 2, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), +ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) +ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) +ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) -ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 2, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), +ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)]) +ch0voltGraph = Graph(layout, 'Channel 0 Voltage', 1, 4, [Curves('Feedback', 'tec_u_meas', 0, 'r', rec_len, refresh_period)]) +ch1voltGraph = Graph(layout, 'Channel 1 Voltage', 2, 4, [Curves('Feedback', 'tec_u_meas', 1, 'r', rec_len, refresh_period)]) cnt = 0 def updateData(): @@ -75,13 +76,13 @@ def updateData(): ch1tempGraph.update(data, cnt) ch0currentGraph.update(data, cnt) ch1currentGraph.update(data, cnt) + ch0voltGraph.update(data, cnt) + ch1voltGraph.update(data, cnt) if quit: break cnt += 1 - -## Start a timer to rapidly update the plot in pw t = QtCore.QTimer() t.timeout.connect(updateData) t.start(refresh_period) From b097067afca7172ff8133d67afb6475d0ec153d6 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Sun, 5 Jun 2022 18:59:05 +0800 Subject: [PATCH 009/247] add param tree, param tree inactive --- pytec/tecQT.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 7bce9f0..92c0306 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -1,4 +1,6 @@ from pyqtgraph.Qt import QtGui, QtCore +import pyqtgraph.parametertree.parameterTypes as pTypes +from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType import numpy as np import pyqtgraph as pg from pytec.client import Client @@ -8,6 +10,64 @@ tec = Client(host="192.168.1.26", port=23, timeout=None) rec_len = 1000 refresh_period = 20 +# Channel 0 or 1 +# +# |- Output enable +# |- Set Constant Current (Disables Constant Temperature) +# |- Set Constant Temperature (Disables Constant Current) +# |- Output Config +# |- Max Current +# |- Max Voltage +# |- Thermistor Config +# |- T0 +# |- R0 +# |- Beta +# |- PID Config +# |- kP +# |- kI +# |- kD +# |- (Auto Tune PID) +# (Save Configs) + +params = [[ + {'name': 'Enable Output', 'type': 'bool', 'value': False}, + {'name': 'Enable Constant Current', 'type': 'bool', 'value': False, 'children': [ + {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, + ]}, + {'name': 'Enable PID', 'type': 'bool', 'value': False, 'children': [ + {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'}, + ]}, + {'name': 'Output Config', 'type': 'group', 'children': [ + {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, + {'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'V'}, + ]}, + {'name': 'Thermistor Config', 'type': 'group', 'children': [ + {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'}, + {'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm'}, + {'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1}, + ]}, + {'name': 'PID Config', 'type': 'group', 'children': [ + {'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1}, + {'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1}, + {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1}, + ]}, + {'name': 'Save', 'type': 'action', 'tip': 'Save'}, +] for _ in range(2)] + +## If anything changes in the tree, print a message +def change(param, changes): + print("tree changes:") + for param, change, data in changes: + path = paramList0.childPath(param) + if path is not None: + childName = '.'.join(path) + else: + childName = param.name() + print(' parameter: %s'% childName) + print(' change: %s'% change) + print(' data: %s'% str(data)) + print(' ----------') + class Curves: def __init__(self, legend: str, key: str, channel: int, color: str, buffer_len: int, period: int): self.curveItem = pg.PlotCurveItem(pen=({'color': color, 'width': 1})) @@ -46,9 +106,10 @@ class Graph: self.plotItem.setRange(xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000]) app = pg.mkQApp() +pg.setConfigOptions(antialias=True) mw = QtGui.QMainWindow() mw.setWindowTitle('Thermostat Control Panel') -mw.resize(1500,800) +mw.resize(1920,1200) cw = QtGui.QWidget() mw.setCentralWidget(cw) l = QtGui.QVBoxLayout() @@ -56,7 +117,19 @@ layout = pg.LayoutWidget() l.addWidget(layout) cw.setLayout(l) -pg.setConfigOptions(antialias=True) +## Create tree of Parameter objects +paramList0 = Parameter.create(name='params', type='group', children=params[0]) +paramList0.sigTreeStateChanged.connect(change) +ch0Tree = ParameterTree() +ch0Tree.setParameters(paramList0, showTop=False) + +paramList1 = Parameter.create(name='params', type='group', children=params[1]) +paramList1.sigTreeStateChanged.connect(change) +ch1Tree = ParameterTree() +ch1Tree.setParameters(paramList1, showTop=False) + +layout.addWidget(ch0Tree, 1, 1, 1, 1) +layout.addWidget(ch1Tree, 2, 1, 1, 1) ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) @@ -64,8 +137,6 @@ ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, [Curves('Feedback', ' Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)]) -ch0voltGraph = Graph(layout, 'Channel 0 Voltage', 1, 4, [Curves('Feedback', 'tec_u_meas', 0, 'r', rec_len, refresh_period)]) -ch1voltGraph = Graph(layout, 'Channel 1 Voltage', 2, 4, [Curves('Feedback', 'tec_u_meas', 1, 'r', rec_len, refresh_period)]) cnt = 0 def updateData(): @@ -76,8 +147,6 @@ def updateData(): ch1tempGraph.update(data, cnt) ch0currentGraph.update(data, cnt) ch1currentGraph.update(data, cnt) - ch0voltGraph.update(data, cnt) - ch1voltGraph.update(data, cnt) if quit: break From 563a32edf4bdebcb4d57f0725fce2063755f0fc2 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Mon, 6 Jun 2022 12:38:44 +0800 Subject: [PATCH 010/247] add sync from TEC --- pytec/tecQT.py | 153 ++++++++++++++++++++++++++++++------------------- 1 file changed, 94 insertions(+), 59 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 92c0306..8b07ff4 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -5,31 +5,38 @@ import numpy as np import pyqtgraph as pg from pytec.client import Client -tec = Client(host="192.168.1.26", port=23, timeout=None) - rec_len = 1000 refresh_period = 20 -# Channel 0 or 1 -# -# |- Output enable -# |- Set Constant Current (Disables Constant Temperature) -# |- Set Constant Temperature (Disables Constant Current) -# |- Output Config -# |- Max Current -# |- Max Voltage -# |- Thermistor Config -# |- T0 -# |- R0 -# |- Beta -# |- PID Config -# |- kP -# |- kI -# |- kD -# |- (Auto Tune PID) -# (Save Configs) +TECparams = [ [ + {'tag': 'report', 'type': 'parent', 'children': [ + {'tag': 'pid_engaged', 'type': 'bool', 'value': False}, + ]}, + {'tag': 'pwm', 'type': 'parent', 'children': [ + {'tag': 'max_i_pos', 'type': 'float', 'value': 0}, + {'tag': 'max_i_neg', 'type': 'float', 'value': 0}, + {'tag': 'max_v', 'type': 'float', 'value': 0}, + {'tag': 'i_set', 'type': 'float', 'value': 0}, + ]}, + {'tag': 'pid', 'type': 'parent', 'children': [ + {'tag': 'kp', 'type': 'float', 'value': 0}, + {'tag': 'ki', 'type': 'float', 'value': 0}, + {'tag': 'kd', 'type': 'float', 'value': 0}, + {'tag': 'output_min', 'type': 'float', 'value': 0}, + {'tag': 'output_max', 'type': 'float', 'value': 0}, + ]}, + {'tag': 's-h', 'type': 'parent', 'children': [ + {'tag': 't0', 'type': 'float', 'value': 0}, + {'tag': 'r0', 'type': 'float', 'value': 0}, + {'tag': 'b', 'type': 'float', 'value': 0}, + ]}, + {'tag': 'PIDtarget', 'type': 'parent', 'children': [ + {'tag': 'target', 'type': 'float', 'value': 0}, + ]}, +] for _ in range(2)] -params = [[ + +GUIparams = [[ {'name': 'Enable Output', 'type': 'bool', 'value': False}, {'name': 'Enable Constant Current', 'type': 'bool', 'value': False, 'children': [ {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, @@ -52,7 +59,7 @@ params = [[ {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1}, ]}, {'name': 'Save', 'type': 'action', 'tip': 'Save'}, -] for _ in range(2)] +] for ch in range(2)] ## If anything changes in the tree, print a message def change(param, changes): @@ -105,38 +112,29 @@ class Graph: curve.update(tec_data, cnt) self.plotItem.setRange(xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000]) -app = pg.mkQApp() -pg.setConfigOptions(antialias=True) -mw = QtGui.QMainWindow() -mw.setWindowTitle('Thermostat Control Panel') -mw.resize(1920,1200) -cw = QtGui.QWidget() -mw.setCentralWidget(cw) -l = QtGui.QVBoxLayout() -layout = pg.LayoutWidget() -l.addWidget(layout) -cw.setLayout(l) +def TECsync(): + global TECparams + for channel in range(2): + for parents in TECparams[channel]: + if parents['tag'] == 'report': + for data in tec.report_mode(): + for children in parents['children']: + children['value'] = data[channel][children['tag']] + if quit: + break + if parents['tag'] == 'pwm': + for children in parents['children']: + children['value'] = tec.get_pwm()[channel][children['tag']]['value'] + if parents['tag'] == 'pid': + for children in parents['children']: + children['value'] = tec.get_pid()[channel]['parameters'][children['tag']] + if parents['tag'] == 's-h': + for children in parents['children']: + children['value'] = tec.get_steinhart_hart()[channel]['params'][children['tag']] + if parents['tag'] == 'PIDtarget': + for children in parents['children']: + children['value'] = tec.get_pid()[channel]['target'] -## Create tree of Parameter objects -paramList0 = Parameter.create(name='params', type='group', children=params[0]) -paramList0.sigTreeStateChanged.connect(change) -ch0Tree = ParameterTree() -ch0Tree.setParameters(paramList0, showTop=False) - -paramList1 = Parameter.create(name='params', type='group', children=params[1]) -paramList1.sigTreeStateChanged.connect(change) -ch1Tree = ParameterTree() -ch1Tree.setParameters(paramList1, showTop=False) - -layout.addWidget(ch0Tree, 1, 1, 1, 1) -layout.addWidget(ch1Tree, 2, 1, 1, 1) - -ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) -ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) -ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), - Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) -ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), - Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)]) cnt = 0 def updateData(): @@ -152,11 +150,48 @@ def updateData(): break cnt += 1 -t = QtCore.QTimer() -t.timeout.connect(updateData) -t.start(refresh_period) - -mw.show() - + if __name__ == '__main__': + tec = Client(host="192.168.1.26", port=23, timeout=None) + TECsync() + + app = pg.mkQApp() + pg.setConfigOptions(antialias=True) + mw = QtGui.QMainWindow() + mw.setWindowTitle('Thermostat Control Panel') + mw.resize(1920,1200) + cw = QtGui.QWidget() + mw.setCentralWidget(cw) + l = QtGui.QVBoxLayout() + layout = pg.LayoutWidget() + l.addWidget(layout) + cw.setLayout(l) + + ## Create tree of Parameter objects + paramList0 = Parameter.create(name='GUIparams', type='group', children=GUIparams[0]) + paramList0.sigTreeStateChanged.connect(change) + ch0Tree = ParameterTree() + ch0Tree.setParameters(paramList0, showTop=False) + + paramList1 = Parameter.create(name='GUIparams', type='group', children=GUIparams[1]) + paramList1.sigTreeStateChanged.connect(change) + ch1Tree = ParameterTree() + ch1Tree.setParameters(paramList1, showTop=False) + + layout.addWidget(ch0Tree, 1, 1, 1, 1) + layout.addWidget(ch1Tree, 2, 1, 1, 1) + + ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) + ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) + ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), + Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) + ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), + Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)]) + + t = QtCore.QTimer() + t.timeout.connect(updateData) + t.start(refresh_period) + + mw.show() + pg.exec() \ No newline at end of file From 593ad9a133f345c5c81f6833fc3487d77f9e7f40 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Mon, 6 Jun 2022 13:49:58 +0800 Subject: [PATCH 011/247] sync tree param from TEC --- pytec/tecQT.py | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 8b07ff4..163a4e0 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -37,29 +37,29 @@ TECparams = [ [ GUIparams = [[ - {'name': 'Enable Output', 'type': 'bool', 'value': False}, - {'name': 'Enable Constant Current', 'type': 'bool', 'value': False, 'children': [ + {'name': 'Disable Output', 'type': 'action', 'tip': 'Disable Output'}, + {'name': 'Constant Current', 'type': 'bool', 'value': False, 'children': [ {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, ]}, - {'name': 'Enable PID', 'type': 'bool', 'value': False, 'children': [ + {'name': 'Temperature PID', 'type': 'bool', 'value': False, 'children': [ {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'}, ]}, - {'name': 'Output Config', 'type': 'group', 'children': [ + {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, {'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'V'}, ]}, - {'name': 'Thermistor Config', 'type': 'group', 'children': [ + {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'}, {'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm'}, {'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1}, ]}, - {'name': 'PID Config', 'type': 'group', 'children': [ + {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1}, {'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1}, {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1}, ]}, {'name': 'Save', 'type': 'action', 'tip': 'Save'}, -] for ch in range(2)] +] for _ in range(2)] ## If anything changes in the tree, print a message def change(param, changes): @@ -134,7 +134,23 @@ def TECsync(): if parents['tag'] == 'PIDtarget': for children in parents['children']: children['value'] = tec.get_pid()[channel]['target'] - + +def refreshTreeParam(tempTree:dict, channel:int) -> dict: + print(tempTree) + print(type(tempTree)) + tempTree['children']['Constant Current']['value'] = not TECparams[channel][0]['children'][0]['value'] + tempTree['children']['Constant Current']['children']['Set Current']['value'] = TECparams[channel][1]['children'][3]['value'] + tempTree['children']['Temperature PID']['value'] = TECparams[channel][0]['children'][0]['value'] + tempTree['children']['Temperature PID']['children']['Set Temperature']['value'] = TECparams[channel][4]['children'][0]['value'] + tempTree['children']['Output Config']['children']['Max Current']['value'] = TECparams[channel][1]['children'][0]['value'] + tempTree['children']['Output Config']['children']['Max Voltage']['value'] = TECparams[channel][1]['children'][2]['value'] + tempTree['children']['Thermistor Config']['children']['T0']['value'] = TECparams[channel][3]['children'][0]['value'] - 273.15 + tempTree['children']['Thermistor Config']['children']['R0']['value'] = TECparams[channel][3]['children'][1]['value'] + tempTree['children']['Thermistor Config']['children']['Beta']['value'] = TECparams[channel][3]['children'][2]['value'] + tempTree['children']['PID Config']['children']['kP']['value'] = TECparams[channel][2]['children'][0]['value'] + tempTree['children']['PID Config']['children']['kI']['value'] = TECparams[channel][2]['children'][1]['value'] + tempTree['children']['PID Config']['children']['kD']['value'] = TECparams[channel][2]['children'][2]['value'] + return tempTree cnt = 0 def updateData(): @@ -150,10 +166,9 @@ def updateData(): break cnt += 1 - + if __name__ == '__main__': tec = Client(host="192.168.1.26", port=23, timeout=None) - TECsync() app = pg.mkQApp() pg.setConfigOptions(antialias=True) @@ -178,6 +193,10 @@ if __name__ == '__main__': ch1Tree = ParameterTree() ch1Tree.setParameters(paramList1, showTop=False) + TECsync() + paramList0.restoreState(refreshTreeParam(paramList0.saveState(), 0)) + paramList1.restoreState(refreshTreeParam(paramList1.saveState(), 1)) + layout.addWidget(ch0Tree, 1, 1, 1, 1) layout.addWidget(ch1Tree, 2, 1, 1, 1) From 2796400d47c465c8dd345b937a461b14d4c98456 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Mon, 6 Jun 2022 15:24:58 +0800 Subject: [PATCH 012/247] bi-dir sync, minimum working prototype --- pytec/tecQT.py | 84 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 15 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 163a4e0..45e5c3e 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -38,7 +38,7 @@ TECparams = [ [ GUIparams = [[ {'name': 'Disable Output', 'type': 'action', 'tip': 'Disable Output'}, - {'name': 'Constant Current', 'type': 'bool', 'value': False, 'children': [ + {'name': 'Constant Current', 'type': 'group', 'children': [ {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, ]}, {'name': 'Temperature PID', 'type': 'bool', 'value': False, 'children': [ @@ -62,10 +62,10 @@ GUIparams = [[ ] for _ in range(2)] ## If anything changes in the tree, print a message -def change(param, changes): +def change(param, changes, ch): print("tree changes:") for param, change, data in changes: - path = paramList0.childPath(param) + path = paramList[ch].childPath(param) if path is not None: childName = '.'.join(path) else: @@ -75,6 +75,60 @@ def change(param, changes): print(' data: %s'% str(data)) print(' ----------') + if (childName == 'Disable Output'): + tec.set_param('pwm', ch, 'i_set', 0) + paramList[ch].child('Constant Current').child('Set Current').setValue(0) + paramList[ch].child('Temperature PID').setValue(False) + + if (childName == 'Temperature PID'): + if (data): + tec.set_param("pwm", ch, "pid") + else: + tec.set_param('pwm', ch, 'i_set', paramList[ch].child('Constant Current').child('Set Current').value()) + + if (childName == 'Constant Current.Set Current'): + tec.set_param('pwm', ch, 'i_set', data) + paramList[ch].child('Temperature PID').setValue(False) + + if (childName == 'Temperature PID.Set Temperature'): + tec.set_param('pid', ch, 'target', data) + + if (childName == 'Output Config.Max Current'): + tec.set_param('pwm', ch, 'max_i_pos', data) + tec.set_param('pwm', ch, 'max_i_neg', data) + tec.set_param('pid', ch, 'output_min', -data) + tec.set_param('pid', ch, 'output_max', data) + + if (childName == 'Output Config.Max Voltage'): + tec.set_param('pwm', ch, 'max_v', data) + + if (childName == 'Thermistor Config.T0'): + tec.set_param('s-h', ch, 't0', data) + + if (childName == 'Thermistor Config.R0'): + tec.set_param('s-h', ch, 'r0', data) + + if (childName == 'Thermistor Config.Beta'): + tec.set_param('s-h', ch, 'b', data) + + if (childName == 'PID Config.kP'): + tec.set_param('pid', ch, 'kp', data) + + if (childName == 'PID Config.kI'): + tec.set_param('pid', ch, 'ki', data) + + if (childName == 'PID Config.kD'): + tec.set_param('pid', ch, 'kd', data) + + if (childName == 'Save'): + tec.save_config() + +def change0(param, changes): + change(param, changes, 0) + +def change1(param, changes): + change(param, changes, 1) + class Curves: def __init__(self, legend: str, key: str, channel: int, color: str, buffer_len: int, period: int): self.curveItem = pg.PlotCurveItem(pen=({'color': color, 'width': 1})) @@ -136,9 +190,7 @@ def TECsync(): children['value'] = tec.get_pid()[channel]['target'] def refreshTreeParam(tempTree:dict, channel:int) -> dict: - print(tempTree) - print(type(tempTree)) - tempTree['children']['Constant Current']['value'] = not TECparams[channel][0]['children'][0]['value'] + # tempTree['children']['Constant Current']['value'] = not TECparams[channel][0]['children'][0]['value'] tempTree['children']['Constant Current']['children']['Set Current']['value'] = TECparams[channel][1]['children'][3]['value'] tempTree['children']['Temperature PID']['value'] = TECparams[channel][0]['children'][0]['value'] tempTree['children']['Temperature PID']['children']['Set Temperature']['value'] = TECparams[channel][4]['children'][0]['value'] @@ -183,19 +235,21 @@ if __name__ == '__main__': cw.setLayout(l) ## Create tree of Parameter objects - paramList0 = Parameter.create(name='GUIparams', type='group', children=GUIparams[0]) - paramList0.sigTreeStateChanged.connect(change) - ch0Tree = ParameterTree() - ch0Tree.setParameters(paramList0, showTop=False) + paramList = [Parameter.create(name='GUIparams', type='group', children=GUIparams[0]), + Parameter.create(name='GUIparams', type='group', children=GUIparams[1])] - paramList1 = Parameter.create(name='GUIparams', type='group', children=GUIparams[1]) - paramList1.sigTreeStateChanged.connect(change) + paramList[0].sigTreeStateChanged.connect(change0) + print(paramList[0].children()) + ch0Tree = ParameterTree() + ch0Tree.setParameters(paramList[0], showTop=False) + + paramList[1].sigTreeStateChanged.connect(change1) ch1Tree = ParameterTree() - ch1Tree.setParameters(paramList1, showTop=False) + ch1Tree.setParameters(paramList[1], showTop=False) TECsync() - paramList0.restoreState(refreshTreeParam(paramList0.saveState(), 0)) - paramList1.restoreState(refreshTreeParam(paramList1.saveState(), 1)) + paramList[0].restoreState(refreshTreeParam(paramList[0].saveState(), 0)) + paramList[1].restoreState(refreshTreeParam(paramList[1].saveState(), 1)) layout.addWidget(ch0Tree, 1, 1, 1, 1) layout.addWidget(ch1Tree, 2, 1, 1, 1) From e71453750c0a6ed6c83de23f712d73a3fef087f3 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Mon, 6 Jun 2022 15:25:37 +0800 Subject: [PATCH 013/247] fix whitespace error --- pytec/pytec/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/pytec/client.py b/pytec/pytec/client.py index 062d8dc..915a9db 100644 --- a/pytec/pytec/client.py +++ b/pytec/pytec/client.py @@ -32,7 +32,7 @@ class Client: return line def _command(self, *command): - self._socket.sendall((" ".join(command) + "\n").encode('utf-8')) + self._socket.sendall(((" ".join(command)).strip() + "\n").encode('utf-8')) line = self._read_line() response = json.loads(line) From f31c3be335cd3ae4b2ea96ab2ac0234bb458fbba Mon Sep 17 00:00:00 2001 From: topquark12 Date: Mon, 6 Jun 2022 21:28:30 +0800 Subject: [PATCH 014/247] fix docs, fix i_set, fix GUI param ranges --- pytec/tecQT.py | 10 +++++----- src/channels.rs | 7 ++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 45e5c3e..c267d6a 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -39,17 +39,17 @@ TECparams = [ [ GUIparams = [[ {'name': 'Disable Output', 'type': 'action', 'tip': 'Disable Output'}, {'name': 'Constant Current', 'type': 'group', 'children': [ - {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, + {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'suffix': 'A'}, ]}, {'name': 'Temperature PID', 'type': 'bool', 'value': False, 'children': [ - {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'}, + {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, 'suffix': 'C'}, ]}, {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, - {'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'siPrefix': True, 'suffix': 'V'}, + {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, 'suffix': 'A'}, + {'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V'}, ]}, {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'}, + {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, 'suffix': 'C'}, {'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm'}, {'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1}, ]}, diff --git a/src/channels.rs b/src/channels.rs index f32a939..d29173c 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -113,7 +113,12 @@ impl<'a> Channels<'a> { } pub fn get_i(&mut self, channel: usize) -> ElectricCurrent { - let center_point = self.get_center(channel); + let center_point = match channel.into() { + 0 => self.channel0.vref_meas, + 1 => self.channel1.vref_meas, + _ => unreachable!(), + }; + // let center_point = self.get_center(channel); let r_sense = ElectricalResistance::new::(R_SENSE); let voltage = self.get_dac(channel); let i_tec = (voltage - center_point) / (10.0 * r_sense); From be4383a4471b564da0d13431055ddf2b294ae626 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Mon, 6 Jun 2022 23:18:44 +0800 Subject: [PATCH 015/247] WIP: adding autotune --- pytec/tecQT.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/pytec/tecQT.py b/pytec/tecQT.py index c267d6a..8ca7fd9 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -4,6 +4,8 @@ from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, reg import numpy as np import pyqtgraph as pg from pytec.client import Client +from enum import Enum +from autotune import PIDAutotune, PIDAutotuneState rec_len = 1000 refresh_period = 20 @@ -35,7 +37,6 @@ TECparams = [ [ ]}, ] for _ in range(2)] - GUIparams = [[ {'name': 'Disable Output', 'type': 'action', 'tip': 'Disable Output'}, {'name': 'Constant Current', 'type': 'group', 'children': [ @@ -57,10 +58,18 @@ GUIparams = [[ {'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1}, {'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1}, {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1}, + {'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, 'suffix': 'C'}, + {'name': 'Run', 'type': 'action', 'tip': 'Run'}, + ]}, ]}, - {'name': 'Save', 'type': 'action', 'tip': 'Save'}, + {'name': 'Save to flash', 'type': 'action', 'tip': 'Save to flash'}, ] for _ in range(2)] +autoTuneState = [PIDAutotuneState.STATE_OFF, 'idle'] + ## If anything changes in the tree, print a message def change(param, changes, ch): print("tree changes:") @@ -120,7 +129,10 @@ def change(param, changes, ch): if (childName == 'PID Config.kD'): tec.set_param('pid', ch, 'kd', data) - if (childName == 'Save'): + if (childName == 'PID Config.PID Auto Tune.Run'): + autoTuneState[ch] = 'triggered' + + if (childName == 'Save to flash'): tec.save_config() def change0(param, changes): @@ -174,6 +186,7 @@ def TECsync(): for data in tec.report_mode(): for children in parents['children']: children['value'] = data[channel][children['tag']] + print(data[channel][children['tag']]) if quit: break if parents['tag'] == 'pwm': @@ -190,7 +203,6 @@ def TECsync(): children['value'] = tec.get_pid()[channel]['target'] def refreshTreeParam(tempTree:dict, channel:int) -> dict: - # tempTree['children']['Constant Current']['value'] = not TECparams[channel][0]['children'][0]['value'] tempTree['children']['Constant Current']['children']['Set Current']['value'] = TECparams[channel][1]['children'][3]['value'] tempTree['children']['Temperature PID']['value'] = TECparams[channel][0]['children'][0]['value'] tempTree['children']['Temperature PID']['children']['Set Temperature']['value'] = TECparams[channel][4]['children'][0]['value'] @@ -213,6 +225,11 @@ def updateData(): ch1tempGraph.update(data, cnt) ch0currentGraph.update(data, cnt) ch1currentGraph.update(data, cnt) + + for state in autoTuneState: + if state == 'triggered': + state = 'tuning' + if quit: break @@ -238,12 +255,8 @@ if __name__ == '__main__': paramList = [Parameter.create(name='GUIparams', type='group', children=GUIparams[0]), Parameter.create(name='GUIparams', type='group', children=GUIparams[1])] - paramList[0].sigTreeStateChanged.connect(change0) - print(paramList[0].children()) ch0Tree = ParameterTree() - ch0Tree.setParameters(paramList[0], showTop=False) - - paramList[1].sigTreeStateChanged.connect(change1) + ch0Tree.setParameters(paramList[0], showTop=False) ch1Tree = ParameterTree() ch1Tree.setParameters(paramList[1], showTop=False) @@ -251,6 +264,9 @@ if __name__ == '__main__': paramList[0].restoreState(refreshTreeParam(paramList[0].saveState(), 0)) paramList[1].restoreState(refreshTreeParam(paramList[1].saveState(), 1)) + paramList[0].sigTreeStateChanged.connect(change0) + paramList[1].sigTreeStateChanged.connect(change1) + layout.addWidget(ch0Tree, 1, 1, 1, 1) layout.addWidget(ch1Tree, 2, 1, 1, 1) From 059dd0bbb66b1dd59649302946f76ca7feb889a5 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Tue, 7 Jun 2022 13:54:18 +0800 Subject: [PATCH 016/247] add autotune --- pytec/autotune.py | 25 ++++++++++++++++++++++++- pytec/tecQT.py | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/pytec/autotune.py b/pytec/autotune.py index c1f593e..bf12432 100644 --- a/pytec/autotune.py +++ b/pytec/autotune.py @@ -17,6 +17,7 @@ class PIDAutotuneState(Enum): STATE_RELAY_STEP_DOWN = 'relay step down' STATE_SUCCEEDED = 'succeeded' STATE_FAILED = 'failed' + STATE_READY = 'ready' class PIDAutotune: @@ -56,6 +57,20 @@ class PIDAutotune: self._Ku = 0 self._Pu = 0 + def setParam(self, target, step, noiseband, sampletime, lookback): + self._setpoint = target + self._outputstep = step + self._out_max = step + self._out_min = -step + self._noiseband = noiseband + self._inputs = deque(maxlen=round(lookback / sampletime)) + + def setReady(self): + self._state = PIDAutotuneState.STATE_READY + + def setOff(self): + self._state = PIDAutotuneState.STATE_OFF + def state(self): """Get the current state.""" return self._state @@ -81,6 +96,13 @@ class PIDAutotune: kd = divisors[2] * self._Ku * self._Pu return PIDAutotune.PIDParams(kp, ki, kd) + def get_tec_pid (self): + divisors = self._tuning_rules["tyreus-luyben"] + kp = self._Ku * divisors[0] + ki = divisors[1] * self._Ku / self._Pu + kd = divisors[2] * self._Ku * self._Pu + return kp, ki, kd + def run(self, input_val, time_input): """To autotune a system, this method must be called periodically. @@ -95,7 +117,8 @@ class PIDAutotune: if (self._state == PIDAutotuneState.STATE_OFF or self._state == PIDAutotuneState.STATE_SUCCEEDED - or self._state == PIDAutotuneState.STATE_FAILED): + or self._state == PIDAutotuneState.STATE_FAILED + or self._state == PIDAutotuneState.STATE_READY): self._state = PIDAutotuneState.STATE_RELAY_STEP_UP self._last_run_timestamp = now diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 8ca7fd9..4a94439 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -68,7 +68,8 @@ GUIparams = [[ {'name': 'Save to flash', 'type': 'action', 'tip': 'Save to flash'}, ] for _ in range(2)] -autoTuneState = [PIDAutotuneState.STATE_OFF, 'idle'] +autoTuner = [PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000), + PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000)] ## If anything changes in the tree, print a message def change(param, changes, ch): @@ -87,7 +88,8 @@ def change(param, changes, ch): if (childName == 'Disable Output'): tec.set_param('pwm', ch, 'i_set', 0) paramList[ch].child('Constant Current').child('Set Current').setValue(0) - paramList[ch].child('Temperature PID').setValue(False) + paramList[ch].child('Temperature PID').setValue(False) + autoTuner[ch].setOff() if (childName == 'Temperature PID'): if (data): @@ -130,7 +132,13 @@ def change(param, changes, ch): tec.set_param('pid', ch, 'kd', data) if (childName == 'PID Config.PID Auto Tune.Run'): - autoTuneState[ch] = 'triggered' + autoTuner[ch].setParam(paramList[ch].child('PID Config').child('PID Auto Tune').child('Target Temperature').value(), + paramList[ch].child('PID Config').child('PID Auto Tune').child('Test Current').value(), + paramList[ch].child('PID Config').child('PID Auto Tune').child('Temperature Swing').value(), + refresh_period / 1000, + 1) + autoTuner[ch].setReady() + paramList[ch].child('Temperature PID').setValue(False) if (childName == 'Save to flash'): tec.save_config() @@ -185,8 +193,8 @@ def TECsync(): if parents['tag'] == 'report': for data in tec.report_mode(): for children in parents['children']: + print(data) children['value'] = data[channel][children['tag']] - print(data[channel][children['tag']]) if quit: break if parents['tag'] == 'pwm': @@ -226,10 +234,24 @@ def updateData(): ch0currentGraph.update(data, cnt) ch1currentGraph.update(data, cnt) - for state in autoTuneState: - if state == 'triggered': - state = 'tuning' - + for channel in range (2): + if (autoTuner[channel].state() == PIDAutotuneState.STATE_READY or + autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_UP or + autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_DOWN): + autoTuner[channel].run(data[channel]['temperature'], data[channel]['time']) + tec.set_param('pwm', channel, 'i_set', autoTuner[channel].output()) + elif (autoTuner[channel].state() == PIDAutotuneState.STATE_SUCCEEDED): + kp, ki, kd = autoTuner[channel].get_tec_pid() + autoTuner[channel].setOff() + paramList[channel].child('PID Config').child('kP').setValue(kp) + paramList[channel].child('PID Config').child('kI').setValue(ki) + paramList[channel].child('PID Config').child('kD').setValue(kd) + tec.set_param('pid', channel, 'kp', kp) + tec.set_param('pid', channel, 'ki', ki) + tec.set_param('pid', channel, 'kd', kd) + elif (autoTuner[channel].state() == PIDAutotuneState.STATE_FAILED): + tec.set_param('pwm', channel, 'i_set', 0) + autoTuner[channel].setOff() if quit: break From 7c1820872b6224e577e4c8fdd84b2b49419ff073 Mon Sep 17 00:00:00 2001 From: topquark12 Date: Thu, 30 Jun 2022 14:25:56 +0800 Subject: [PATCH 017/247] update docs --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b0b5d1a..24fcc45 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,21 @@ On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit" ``` -## Network +## GUI Usage + +A GUI has been developed for easy configuration and plotting of key parameters. + +The Python GUI program is located at pytec/tecQT.py + +The GUI is developed based on the Python library pyqtgraph. The environment needed to run the GUI is configured automatically by running: + +```shell +nix develop +``` + +The GUI program assumes the default IP and port of 192.168.1.26 23 is used. If a different IP or port is used, the IP and port setting should be changed in the GUI code. + +## Command Line Usage ### Connecting From 6de0d41c2376326f471048fdb78c9e108d5e0f69 Mon Sep 17 00:00:00 2001 From: Egor Savkin Date: Fri, 19 May 2023 11:22:01 +0800 Subject: [PATCH 018/247] Update nix repos Signed-off-by: Egor Savkin --- flake.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 76a885d..485c03b 100644 --- a/flake.nix +++ b/flake.nix @@ -69,12 +69,12 @@ buildInputs = with pkgs; [ rust openocd dfu-util ] ++ (with python3Packages; [ - numpy matplotlib pyqtgraph + numpy matplotlib pyqtgraph setuptools pyqt6 ]); shellHook= '' - export QT_PLUGIN_PATH=${pkgs.qt5.qtbase}/${pkgs.qt5.qtbase.dev.qtPluginPrefix} - export QML2_IMPORT_PATH=${pkgs.qt5.qtbase}/${pkgs.qt5.qtbase.dev.qtQmlPrefix} + export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.dev.qtPluginPrefix} + export QML2_IMPORT_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.dev.qtQmlPrefix} ''; }; defaultPackage.x86_64-linux = thermostat; From 069280feb620a6a1cdbf1b0ef285fc77f6234581 Mon Sep 17 00:00:00 2001 From: Egor Savkin Date: Fri, 19 May 2023 11:23:39 +0800 Subject: [PATCH 019/247] Create basic GUI, that would connect and control thermostat's fan Signed-off-by: Egor Savkin --- pytec/pytec/client.py | 12 + pytec/tecQT.py | 177 ++++++----- pytec/tec_qt.py | 183 ++++++++++++ pytec/tec_qt.ui | 671 ++++++++++++++++++++++++++++++++++++++++++ pytec/ui_tec_qt.py | 323 ++++++++++++++++++++ 5 files changed, 1291 insertions(+), 75 deletions(-) create mode 100644 pytec/tec_qt.py create mode 100644 pytec/tec_qt.ui create mode 100644 pytec/ui_tec_qt.py diff --git a/pytec/pytec/client.py b/pytec/pytec/client.py index 915a9db..3a78b46 100644 --- a/pytec/pytec/client.py +++ b/pytec/pytec/client.py @@ -11,6 +11,10 @@ class Client: self._lines = [""] self._check_zero_limits() + def disconnect(self): + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + def _check_zero_limits(self): pwm_report = self.get_pwm() for pwm_channel in pwm_report: @@ -167,3 +171,11 @@ class Client: def load_config(self): """Load current configuration from EEPROM""" self._command("load") + + def hw_rev(self): + """Get Thermostat hardware revision""" + return self._command("hwrev") + + def fan(self): + """Get Thermostat current fan settings""" + return self._command("fan") diff --git a/pytec/tecQT.py b/pytec/tecQT.py index 4a94439..289ad01 100644 --- a/pytec/tecQT.py +++ b/pytec/tecQT.py @@ -10,7 +10,7 @@ from autotune import PIDAutotune, PIDAutotuneState rec_len = 1000 refresh_period = 20 -TECparams = [ [ +TECparams = [[ {'tag': 'report', 'type': 'parent', 'children': [ {'tag': 'pid_engaged', 'type': 'bool', 'value': False}, ]}, @@ -40,25 +40,30 @@ TECparams = [ [ GUIparams = [[ {'name': 'Disable Output', 'type': 'action', 'tip': 'Disable Output'}, {'name': 'Constant Current', 'type': 'group', 'children': [ - {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'suffix': 'A'}, - ]}, - {'name': 'Temperature PID', 'type': 'bool', 'value': False, 'children': [ - {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, 'suffix': 'C'}, - ]}, - {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, 'suffix': 'A'}, - {'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V'}, + {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, + 'suffix': 'A'}, ]}, - {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, 'suffix': 'C'}, + {'name': 'Temperature PID', 'type': 'bool', 'value': False, 'children': [ + {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, + 'suffix': 'C'}, + ]}, + {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, + 'suffix': 'A'}, + {'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, + 'suffix': 'V'}, + ]}, + {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, + 'suffix': 'C'}, {'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm'}, {'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1}, ]}, - {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1}, {'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1}, {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1}, - {'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [ + {'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, 'suffix': 'C'}, @@ -71,7 +76,8 @@ GUIparams = [[ autoTuner = [PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000), PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000)] -## If anything changes in the tree, print a message + +# If anything changes in the tree, print a message def change(param, changes, ch): print("tree changes:") for param, change, data in changes: @@ -80,75 +86,79 @@ def change(param, changes, ch): childName = '.'.join(path) else: childName = param.name() - print(' parameter: %s'% childName) - print(' change: %s'% change) - print(' data: %s'% str(data)) + print(' parameter: %s' % childName) + print(' change: %s' % change) + print(' data: %s' % str(data)) print(' ----------') - if (childName == 'Disable Output'): + if childName == 'Disable Output': tec.set_param('pwm', ch, 'i_set', 0) - paramList[ch].child('Constant Current').child('Set Current').setValue(0) - paramList[ch].child('Temperature PID').setValue(False) - autoTuner[ch].setOff() + paramList[ch].child('Constant Current').child('Set Current').setValue(0) + paramList[ch].child('Temperature PID').setValue(False) + autoTuner[ch].setOff() - if (childName == 'Temperature PID'): + if childName == 'Temperature PID': if (data): tec.set_param("pwm", ch, "pid") else: - tec.set_param('pwm', ch, 'i_set', paramList[ch].child('Constant Current').child('Set Current').value()) - - if (childName == 'Constant Current.Set Current'): - tec.set_param('pwm', ch, 'i_set', data) - paramList[ch].child('Temperature PID').setValue(False) + tec.set_param('pwm', ch, 'i_set', paramList[ch].child('Constant Current').child('Set Current').value()) - if (childName == 'Temperature PID.Set Temperature'): + if childName == 'Constant Current.Set Current': + tec.set_param('pwm', ch, 'i_set', data) + paramList[ch].child('Temperature PID').setValue(False) + + if childName == 'Temperature PID.Set Temperature': tec.set_param('pid', ch, 'target', data) - if (childName == 'Output Config.Max Current'): + if childName == 'Output Config.Max Current': tec.set_param('pwm', ch, 'max_i_pos', data) tec.set_param('pwm', ch, 'max_i_neg', data) tec.set_param('pid', ch, 'output_min', -data) tec.set_param('pid', ch, 'output_max', data) - if (childName == 'Output Config.Max Voltage'): + if childName == 'Output Config.Max Voltage': tec.set_param('pwm', ch, 'max_v', data) - if (childName == 'Thermistor Config.T0'): + if childName == 'Thermistor Config.T0': tec.set_param('s-h', ch, 't0', data) - if (childName == 'Thermistor Config.R0'): + if childName == 'Thermistor Config.R0': tec.set_param('s-h', ch, 'r0', data) - if (childName == 'Thermistor Config.Beta'): + if childName == 'Thermistor Config.Beta': tec.set_param('s-h', ch, 'b', data) - if (childName == 'PID Config.kP'): + if childName == 'PID Config.kP': tec.set_param('pid', ch, 'kp', data) - if (childName == 'PID Config.kI'): + if childName == 'PID Config.kI': tec.set_param('pid', ch, 'ki', data) - if (childName == 'PID Config.kD'): + if childName == 'PID Config.kD': tec.set_param('pid', ch, 'kd', data) - if (childName == 'PID Config.PID Auto Tune.Run'): - autoTuner[ch].setParam(paramList[ch].child('PID Config').child('PID Auto Tune').child('Target Temperature').value(), - paramList[ch].child('PID Config').child('PID Auto Tune').child('Test Current').value(), - paramList[ch].child('PID Config').child('PID Auto Tune').child('Temperature Swing').value(), - refresh_period / 1000, - 1) + if childName == 'PID Config.PID Auto Tune.Run': + autoTuner[ch].setParam( + paramList[ch].child('PID Config').child('PID Auto Tune').child('Target Temperature').value(), + paramList[ch].child('PID Config').child('PID Auto Tune').child('Test Current').value(), + paramList[ch].child('PID Config').child('PID Auto Tune').child('Temperature Swing').value(), + refresh_period / 1000, + 1) autoTuner[ch].setReady() - paramList[ch].child('Temperature PID').setValue(False) + paramList[ch].child('Temperature PID').setValue(False) - if (childName == 'Save to flash'): + if childName == 'Save to flash': tec.save_config() - + + def change0(param, changes): change(param, changes, 0) + def change1(param, changes): change(param, changes, 1) + class Curves: def __init__(self, legend: str, key: str, channel: int, color: str, buffer_len: int, period: int): self.curveItem = pg.PlotCurveItem(pen=({'color': color, 'width': 1})) @@ -159,21 +169,22 @@ class Curves: self.time_stamp = np.zeros(buffer_len) self.buffLen = buffer_len self.period = period - + def update(self, tec_data, cnt): if cnt == 0: np.copyto(self.data_buf, np.full(self.buffLen, tec_data[self.channel][self.keyStr])) - else: + else: self.data_buf[:-1] = self.data_buf[1:] self.data_buf[-1] = tec_data[self.channel][self.keyStr] self.time_stamp[:-1] = self.time_stamp[1:] self.time_stamp[-1] = cnt * self.period / 1000 - self.curveItem.setData(x = self.time_stamp, y = self.data_buf) + self.curveItem.setData(x=self.time_stamp, y=self.data_buf) + class Graph: def __init__(self, parent: pg.LayoutWidget, title: str, row: int, col: int, curves: list[Curves]): self.plotItem = pg.PlotWidget(title=title) - self.legendItem = pg.LegendItem(offset=(75, 30), brush=(50,50,200,150)) + self.legendItem = pg.LegendItem(offset=(75, 30), brush=(50, 50, 200, 150)) self.legendItem.setParentItem(self.plotItem.getPlotItem()) parent.addWidget(self.plotItem, row, col) self.curves = curves @@ -184,7 +195,9 @@ class Graph: def update(self, tec_data, cnt): for curve in self.curves: curve.update(tec_data, cnt) - self.plotItem.setRange(xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000]) + self.plotItem.setRange( + xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000]) + def TECsync(): global TECparams @@ -209,22 +222,32 @@ def TECsync(): if parents['tag'] == 'PIDtarget': for children in parents['children']: children['value'] = tec.get_pid()[channel]['target'] - -def refreshTreeParam(tempTree:dict, channel:int) -> dict: - tempTree['children']['Constant Current']['children']['Set Current']['value'] = TECparams[channel][1]['children'][3]['value'] + + +def refreshTreeParam(tempTree: dict, channel: int) -> dict: + tempTree['children']['Constant Current']['children']['Set Current']['value'] = TECparams[channel][1]['children'][3][ + 'value'] tempTree['children']['Temperature PID']['value'] = TECparams[channel][0]['children'][0]['value'] - tempTree['children']['Temperature PID']['children']['Set Temperature']['value'] = TECparams[channel][4]['children'][0]['value'] - tempTree['children']['Output Config']['children']['Max Current']['value'] = TECparams[channel][1]['children'][0]['value'] - tempTree['children']['Output Config']['children']['Max Voltage']['value'] = TECparams[channel][1]['children'][2]['value'] - tempTree['children']['Thermistor Config']['children']['T0']['value'] = TECparams[channel][3]['children'][0]['value'] - 273.15 + tempTree['children']['Temperature PID']['children']['Set Temperature']['value'] = \ + TECparams[channel][4]['children'][0]['value'] + tempTree['children']['Output Config']['children']['Max Current']['value'] = TECparams[channel][1]['children'][0][ + 'value'] + tempTree['children']['Output Config']['children']['Max Voltage']['value'] = TECparams[channel][1]['children'][2][ + 'value'] + tempTree['children']['Thermistor Config']['children']['T0']['value'] = TECparams[channel][3]['children'][0][ + 'value'] - 273.15 tempTree['children']['Thermistor Config']['children']['R0']['value'] = TECparams[channel][3]['children'][1]['value'] - tempTree['children']['Thermistor Config']['children']['Beta']['value'] = TECparams[channel][3]['children'][2]['value'] + tempTree['children']['Thermistor Config']['children']['Beta']['value'] = TECparams[channel][3]['children'][2][ + 'value'] tempTree['children']['PID Config']['children']['kP']['value'] = TECparams[channel][2]['children'][0]['value'] tempTree['children']['PID Config']['children']['kI']['value'] = TECparams[channel][2]['children'][1]['value'] tempTree['children']['PID Config']['children']['kD']['value'] = TECparams[channel][2]['children'][2]['value'] return tempTree + cnt = 0 + + def updateData(): global cnt for data in tec.report_mode(): @@ -234,13 +257,13 @@ def updateData(): ch0currentGraph.update(data, cnt) ch1currentGraph.update(data, cnt) - for channel in range (2): + for channel in range(2): if (autoTuner[channel].state() == PIDAutotuneState.STATE_READY or - autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_UP or - autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_DOWN): + autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_UP or + autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_DOWN): autoTuner[channel].run(data[channel]['temperature'], data[channel]['time']) tec.set_param('pwm', channel, 'i_set', autoTuner[channel].output()) - elif (autoTuner[channel].state() == PIDAutotuneState.STATE_SUCCEEDED): + elif autoTuner[channel].state() == PIDAutotuneState.STATE_SUCCEEDED: kp, ki, kd = autoTuner[channel].get_tec_pid() autoTuner[channel].setOff() paramList[channel].child('PID Config').child('kP').setValue(kp) @@ -249,14 +272,14 @@ def updateData(): tec.set_param('pid', channel, 'kp', kp) tec.set_param('pid', channel, 'ki', ki) tec.set_param('pid', channel, 'kd', kd) - elif (autoTuner[channel].state() == PIDAutotuneState.STATE_FAILED): + elif autoTuner[channel].state() == PIDAutotuneState.STATE_FAILED: tec.set_param('pwm', channel, 'i_set', 0) autoTuner[channel].setOff() - + if quit: break - cnt += 1 - + cnt += 1 + if __name__ == '__main__': tec = Client(host="192.168.1.26", port=23, timeout=None) @@ -265,7 +288,7 @@ if __name__ == '__main__': pg.setConfigOptions(antialias=True) mw = QtGui.QMainWindow() mw.setWindowTitle('Thermostat Control Panel') - mw.resize(1920,1200) + mw.resize(1920, 1200) cw = QtGui.QWidget() mw.setCentralWidget(cw) l = QtGui.QVBoxLayout() @@ -278,7 +301,7 @@ if __name__ == '__main__': Parameter.create(name='GUIparams', type='group', children=GUIparams[1])] ch0Tree = ParameterTree() - ch0Tree.setParameters(paramList[0], showTop=False) + ch0Tree.setParameters(paramList[0], showTop=False) ch1Tree = ParameterTree() ch1Tree.setParameters(paramList[1], showTop=False) @@ -292,12 +315,16 @@ if __name__ == '__main__': layout.addWidget(ch0Tree, 1, 1, 1, 1) layout.addWidget(ch1Tree, 2, 1, 1, 1) - ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) - ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) - ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), - Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) - ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), - Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)]) + ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, + [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) + ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, + [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) + ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, + [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), + Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) + ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, + [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), + Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)]) t = QtCore.QTimer() t.timeout.connect(updateData) @@ -305,4 +332,4 @@ if __name__ == '__main__': mw.show() - pg.exec() \ No newline at end of file + pg.exec() diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py new file mode 100644 index 0000000..8ee039a --- /dev/null +++ b/pytec/tec_qt.py @@ -0,0 +1,183 @@ +from PyQt6 import QtWidgets, uic +from PyQt6.QtCore import QThread, QThreadPool, pyqtSignal, QRunnable, QObject, QSignalBlocker, pyqtSlot +from pyqtgraph import PlotWidget +from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType +import pyqtgraph as pg +import sys +import argparse +import logging +from pytec.client import Client + +# pyuic6 -x tec_qt.ui -o ui_tec_qt.py +from ui_tec_qt import Ui_MainWindow + +tec_client: Client = None + +# ui = None +ui: Ui_MainWindow = None + +thread_pool = QThreadPool.globalInstance() +connection_watcher = None + + +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 WatchConnectTask(QThread): + connected = pyqtSignal(bool) + hw_rev = pyqtSignal(dict) + connecting = pyqtSignal() + fan_update = pyqtSignal(object) + + def __init__(self, ip, port, parent): + self.ip = ip + self.port = port + super().__init__(parent) + + def run(self): + global tec_client + try: + if tec_client: + tec_client.disconnect() + tec_client = None + self.connected.emit(False) + else: + self.connecting.emit() + tec_client = Client(host=self.ip, port=self.port, timeout=30) + self.connected.emit(True) + self.hw_rev.emit(tec_client.hw_rev()) + self.fan_update.emit(tec_client.fan()) + except Exception as e: + logging.error(f"Failed communicating to the {self.ip}:{self.port}: {e}") + self.connected.emit(False) + + @pyqtSlot() + def client_disconnected(self): + global tec_client + if tec_client: + tec_client.disconnect() + tec_client = None + self.connected.emit(False) + + +class ClientTask(QRunnable): + def __init__(self, func, *args, **kwargs): + self.func = func + self.args = args + self.kwargs = kwargs + super().__init__() + + def run(self) -> None: + try: + self.func(*self.args, **self.kwargs) + except (TimeoutError, OSError): + logging.warning("Client connection error, disconnecting", exc_info=True) + if connection_watcher: + connection_watcher.client_disconnected() + + +def connected(result): + ui.graph_group.setEnabled(result) + ui.hw_rev_lbl.setEnabled(result) + ui.fan_group.setEnabled(result) + ui.report_group.setEnabled(result) + + ui.ip_set_line.setEnabled(not result) + ui.port_set_spin.setEnabled(not result) + ui.status_lbl.setText("Connected" if result else "Disconnected") + ui.connect_btn.setText("Disconnect" if result else "Connect") + if not result: + ui.hw_rev_lbl.setText("Thermostat vX.Y") + ui.fan_group.setStyleSheet("") + + +def hw_rev(hw_rev_d: dict): + logging.debug(hw_rev_d) + ui.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['major']}") + ui.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"]) + if hw_rev_d["settings"]["fan_pwm_recommended"]: + ui.fan_group.setStyleSheet("") + ui.fan_group.setToolTip("") + else: + ui.fan_group.setStyleSheet("background-color: yellow") + ui.fan_group.setToolTip("Changing the fan settings of not recommended") + + +def fan_update(fan_settings): + logging.debug(fan_settings) + if fan_settings is None: + return + with QSignalBlocker(ui.fan_power_slider) as _: + ui.fan_power_slider.setValue(fan_settings["fan_pwm"]) + ui.fan_power_slider.setEnabled(not fan_settings["auto_mode"]) + with QSignalBlocker(ui.fan_auto_box) as _: + ui.fan_auto_box.setChecked(fan_settings["auto_mode"]) + + +def fan_set(): + global tec_client + if tec_client is None or ui.fan_auto_box.isChecked(): + return + thread_pool.start(ClientTask(lambda: tec_client.set_param("fan", ui.fan_power_slider.value()))) + + +def fan_auto_set(enabled): + global tec_client + if tec_client is None: + return + ui.fan_power_slider.setEnabled(not enabled) + if enabled: + thread_pool.start(ClientTask(lambda: tec_client.set_param("fan", "auto"))) + else: + thread_pool.start(ClientTask(lambda: tec_client.set_param("fan", ui.fan_power_slider.value()))) + + +def connect(): + global connection_watcher + connection_watcher = WatchConnectTask(ui.ip_set_line.text(), ui.port_set_spin.value(), ui.main_widget) + connection_watcher.connected.connect(connected) + connection_watcher.connecting.connect(lambda: ui.status_lbl.setText("Connecting...")) + connection_watcher.hw_rev.connect(hw_rev) + connection_watcher.fan_update.connect(fan_update) + connection_watcher.start() + + +def main(): + global ui + args = get_argparser().parse_args() + if args.logLevel: + logging.basicConfig(level=getattr(logging, args.logLevel)) + + app = QtWidgets.QApplication(sys.argv) + main_window = QtWidgets.QMainWindow() + #ui = Ui_MainWindow() + #ui.setupUi(main_window) + ui = uic.loadUi('tec_qt.ui', main_window) + + ui.connect_btn.clicked.connect(lambda _checked: connect()) + ui.fan_power_slider.valueChanged.connect(fan_set) + ui.fan_auto_box.stateChanged.connect(fan_auto_set) + + if args.connect: + if args.IP: + ui.ip_set_line.setText(args.IP) + if args.PORT: + ui.port_set_spin.setValue(int(args.PORT)) + ui.connect_btn.click() + + main_window.show() + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui new file mode 100644 index 0000000..58f5849 --- /dev/null +++ b/pytec/tec_qt.ui @@ -0,0 +1,671 @@ + + + MainWindow + + + + 0 + 0 + 1280 + 720 + + + + + 1280 + 720 + + + + + 3840 + 2160 + + + + Control TEC + + + + + 1 + 1 + + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 0 + + + + + false + + + + 1 + 1 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + QLayout::SetDefaultConstraint + + + 3 + + + 3 + + + 3 + + + 3 + + + 2 + + + + + + + + + + + Channel 1 Temperature + + + + + + + Channel 0 Temperature + + + + + + + Channel 0 Current + + + + + + + Channel 1 Current + + + + + + + + + + + 0 + 0 + + + + + 0 + 40 + + + + + 16777215 + 40 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + + + 0 + 0 + + + + + 160 + 0 + + + + + 160 + 16777215 + + + + 192.168.1.26 + + + 15 + + + IP:port for the Thermostat + + + true + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + 65535 + + + 23 + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + + 100 + 0 + + + + Connect + + + + + + + + 0 + 0 + + + + + 120 + 0 + + + + + 120 + 16777215 + + + + + 120 + 50 + + + + Disconnected + + + + + + + + 0 + 0 + + + + Qt::Vertical + + + + + + + false + + + + 0 + 0 + + + + + 40 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 6 + + + QLayout::SetDefaultConstraint + + + 0 + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + + 70 + 0 + + + + 1 + + + 0.100000000000000 + + + 0.100000000000000 + + + QAbstractSpinBox::AdaptiveDecimalStepType + + + 1.000000000000000 + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 80 + 0 + + + + Report + + + + + + + + 0 + 0 + + + + + 80 + 0 + + + + + 80 + 16777215 + + + + + 80 + 0 + + + + Apply + + + + + + + + + + + + + 0 + 0 + + + + Qt::Vertical + + + + + + + false + + + + 40 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 9 + + + + + + 0 + 0 + + + + + 40 + 0 + + + + + 40 + 16777215 + + + + + 40 + 0 + + + + Fan: + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + + 200 + 16777215 + + + + + 200 + 0 + + + + 100 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + Auto + + + + + + + + + + + + + 0 + 0 + + + + Qt::Vertical + + + + + + + false + + + + 0 + 0 + + + + + 150 + 0 + + + + + 150 + 16777215 + + + + + 150 + 0 + + + + Thermostat vX.Y + + + + + + + + + + + + + + + + PlotWidget + QWidget +
pyqtgraph
+ 1 +
+ + ParameterTree + QWidget +
pyqtgraph.parametertree
+ 1 +
+
+ + +
diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py new file mode 100644 index 0000000..b26d878 --- /dev/null +++ b/pytec/ui_tec_qt.py @@ -0,0 +1,323 @@ +# Form implementation generated from reading ui file 'tec_qt.ui' +# +# Created by: PyQt6 UI code generator 6.4.2 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(1280, 720) + MainWindow.setMinimumSize(QtCore.QSize(1280, 720)) + MainWindow.setMaximumSize(QtCore.QSize(3840, 2160)) + self.main_widget = QtWidgets.QWidget(parent=MainWindow) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(1) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(self.main_widget.sizePolicy().hasHeightForWidth()) + self.main_widget.setSizePolicy(sizePolicy) + self.main_widget.setObjectName("main_widget") + self.gridLayout_2 = QtWidgets.QGridLayout(self.main_widget) + self.gridLayout_2.setContentsMargins(3, 3, 3, 3) + self.gridLayout_2.setSpacing(3) + self.gridLayout_2.setObjectName("gridLayout_2") + self.main_layout = QtWidgets.QVBoxLayout() + self.main_layout.setSpacing(0) + self.main_layout.setObjectName("main_layout") + self.graph_group = QtWidgets.QFrame(parent=self.main_widget) + self.graph_group.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(1) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(self.graph_group.sizePolicy().hasHeightForWidth()) + self.graph_group.setSizePolicy(sizePolicy) + self.graph_group.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) + self.graph_group.setFrameShadow(QtWidgets.QFrame.Shadow.Raised) + self.graph_group.setObjectName("graph_group") + self.graphs_layout = QtWidgets.QGridLayout(self.graph_group) + self.graphs_layout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetDefaultConstraint) + self.graphs_layout.setContentsMargins(3, 3, 3, 3) + self.graphs_layout.setSpacing(2) + self.graphs_layout.setObjectName("graphs_layout") + self.ch1_tree = ParameterTree(parent=self.graph_group) + self.ch1_tree.setObjectName("ch1_tree") + self.graphs_layout.addWidget(self.ch1_tree, 1, 0, 1, 1) + self.ch0_tree = ParameterTree(parent=self.graph_group) + self.ch0_tree.setObjectName("ch0_tree") + self.graphs_layout.addWidget(self.ch0_tree, 0, 0, 1, 1) + self.ch1_t_graph = PlotWidget(parent=self.graph_group) + self.ch1_t_graph.setObjectName("ch1_t_graph") + self.graphs_layout.addWidget(self.ch1_t_graph, 1, 1, 1, 1) + self.ch0_t_graph = PlotWidget(parent=self.graph_group) + self.ch0_t_graph.setObjectName("ch0_t_graph") + self.graphs_layout.addWidget(self.ch0_t_graph, 0, 1, 1, 1) + self.ch0_i_graph = PlotWidget(parent=self.graph_group) + self.ch0_i_graph.setObjectName("ch0_i_graph") + self.graphs_layout.addWidget(self.ch0_i_graph, 0, 2, 1, 1) + self.ch1_i_graph = PlotWidget(parent=self.graph_group) + self.ch1_i_graph.setObjectName("ch1_i_graph") + self.graphs_layout.addWidget(self.ch1_i_graph, 1, 2, 1, 1) + self.graphs_layout.setColumnMinimumWidth(0, 100) + self.graphs_layout.setColumnMinimumWidth(1, 100) + self.graphs_layout.setColumnMinimumWidth(2, 100) + self.graphs_layout.setRowMinimumHeight(0, 100) + self.graphs_layout.setRowMinimumHeight(1, 100) + self.graphs_layout.setColumnStretch(0, 1) + self.graphs_layout.setColumnStretch(1, 1) + self.graphs_layout.setColumnStretch(2, 1) + self.graphs_layout.setRowStretch(0, 1) + self.graphs_layout.setRowStretch(1, 1) + self.main_layout.addWidget(self.graph_group) + self.bottom_settings_group = QtWidgets.QFrame(parent=self.main_widget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.bottom_settings_group.sizePolicy().hasHeightForWidth()) + self.bottom_settings_group.setSizePolicy(sizePolicy) + self.bottom_settings_group.setMinimumSize(QtCore.QSize(0, 40)) + self.bottom_settings_group.setMaximumSize(QtCore.QSize(16777215, 40)) + self.bottom_settings_group.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) + self.bottom_settings_group.setFrameShadow(QtWidgets.QFrame.Shadow.Raised) + self.bottom_settings_group.setObjectName("bottom_settings_group") + self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.bottom_settings_group) + self.horizontalLayout_2.setContentsMargins(3, 3, 3, 3) + self.horizontalLayout_2.setSpacing(3) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.settings_layout = QtWidgets.QHBoxLayout() + self.settings_layout.setObjectName("settings_layout") + self.ip_set_line = QtWidgets.QLineEdit(parent=self.bottom_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.ip_set_line.sizePolicy().hasHeightForWidth()) + self.ip_set_line.setSizePolicy(sizePolicy) + self.ip_set_line.setMinimumSize(QtCore.QSize(160, 0)) + self.ip_set_line.setMaximumSize(QtCore.QSize(160, 16777215)) + self.ip_set_line.setMaxLength(15) + self.ip_set_line.setClearButtonEnabled(True) + self.ip_set_line.setObjectName("ip_set_line") + self.settings_layout.addWidget(self.ip_set_line) + self.port_set_spin = QtWidgets.QSpinBox(parent=self.bottom_settings_group) + 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.setProperty("value", 23) + self.port_set_spin.setObjectName("port_set_spin") + self.settings_layout.addWidget(self.port_set_spin) + self.connect_btn = QtWidgets.QPushButton(parent=self.bottom_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.connect_btn.sizePolicy().hasHeightForWidth()) + self.connect_btn.setSizePolicy(sizePolicy) + self.connect_btn.setMinimumSize(QtCore.QSize(100, 0)) + self.connect_btn.setMaximumSize(QtCore.QSize(100, 16777215)) + self.connect_btn.setBaseSize(QtCore.QSize(100, 0)) + self.connect_btn.setObjectName("connect_btn") + self.settings_layout.addWidget(self.connect_btn) + self.status_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.status_lbl.sizePolicy().hasHeightForWidth()) + self.status_lbl.setSizePolicy(sizePolicy) + self.status_lbl.setMinimumSize(QtCore.QSize(120, 0)) + self.status_lbl.setMaximumSize(QtCore.QSize(120, 16777215)) + self.status_lbl.setBaseSize(QtCore.QSize(120, 50)) + self.status_lbl.setObjectName("status_lbl") + self.settings_layout.addWidget(self.status_lbl) + self.line_0 = QtWidgets.QFrame(parent=self.bottom_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.line_0.sizePolicy().hasHeightForWidth()) + self.line_0.setSizePolicy(sizePolicy) + self.line_0.setFrameShape(QtWidgets.QFrame.Shape.VLine) + self.line_0.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + self.line_0.setObjectName("line_0") + self.settings_layout.addWidget(self.line_0) + self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group) + self.report_group.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.report_group.sizePolicy().hasHeightForWidth()) + self.report_group.setSizePolicy(sizePolicy) + self.report_group.setMinimumSize(QtCore.QSize(40, 0)) + self.report_group.setObjectName("report_group") + self.horizontalLayout_4 = QtWidgets.QHBoxLayout(self.report_group) + self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout_4.setSpacing(0) + self.horizontalLayout_4.setObjectName("horizontalLayout_4") + self.report_layout = QtWidgets.QHBoxLayout() + self.report_layout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetDefaultConstraint) + self.report_layout.setContentsMargins(0, -1, -1, -1) + self.report_layout.setSpacing(6) + self.report_layout.setObjectName("report_layout") + self.report_refresh_spin = QtWidgets.QDoubleSpinBox(parent=self.report_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.report_refresh_spin.sizePolicy().hasHeightForWidth()) + self.report_refresh_spin.setSizePolicy(sizePolicy) + self.report_refresh_spin.setMinimumSize(QtCore.QSize(70, 0)) + self.report_refresh_spin.setMaximumSize(QtCore.QSize(70, 16777215)) + self.report_refresh_spin.setBaseSize(QtCore.QSize(70, 0)) + self.report_refresh_spin.setDecimals(1) + self.report_refresh_spin.setMinimum(0.1) + self.report_refresh_spin.setSingleStep(0.1) + self.report_refresh_spin.setStepType(QtWidgets.QAbstractSpinBox.StepType.AdaptiveDecimalStepType) + self.report_refresh_spin.setProperty("value", 1.0) + self.report_refresh_spin.setObjectName("report_refresh_spin") + self.report_layout.addWidget(self.report_refresh_spin) + self.report_box = QtWidgets.QCheckBox(parent=self.report_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.report_box.sizePolicy().hasHeightForWidth()) + self.report_box.setSizePolicy(sizePolicy) + self.report_box.setMaximumSize(QtCore.QSize(80, 16777215)) + self.report_box.setBaseSize(QtCore.QSize(80, 0)) + self.report_box.setObjectName("report_box") + self.report_layout.addWidget(self.report_box) + self.report_apply_btn = QtWidgets.QPushButton(parent=self.report_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.report_apply_btn.sizePolicy().hasHeightForWidth()) + self.report_apply_btn.setSizePolicy(sizePolicy) + self.report_apply_btn.setMinimumSize(QtCore.QSize(80, 0)) + self.report_apply_btn.setMaximumSize(QtCore.QSize(80, 16777215)) + self.report_apply_btn.setBaseSize(QtCore.QSize(80, 0)) + self.report_apply_btn.setObjectName("report_apply_btn") + self.report_layout.addWidget(self.report_apply_btn) + self.report_layout.setStretch(0, 1) + self.report_layout.setStretch(1, 1) + self.report_layout.setStretch(2, 1) + self.horizontalLayout_4.addLayout(self.report_layout) + self.settings_layout.addWidget(self.report_group) + self.line_1 = QtWidgets.QFrame(parent=self.bottom_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.line_1.sizePolicy().hasHeightForWidth()) + self.line_1.setSizePolicy(sizePolicy) + self.line_1.setFrameShape(QtWidgets.QFrame.Shape.VLine) + self.line_1.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + self.line_1.setObjectName("line_1") + self.settings_layout.addWidget(self.line_1) + self.fan_group = QtWidgets.QWidget(parent=self.bottom_settings_group) + self.fan_group.setEnabled(False) + self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_group.setObjectName("fan_group") + self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group) + self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout_6.setSpacing(0) + self.horizontalLayout_6.setObjectName("horizontalLayout_6") + self.gan_layout = QtWidgets.QHBoxLayout() + self.gan_layout.setSpacing(9) + self.gan_layout.setObjectName("gan_layout") + 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.fan_lbl.setObjectName("fan_lbl") + 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.setMaximum(100) + self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.fan_power_slider.setObjectName("fan_power_slider") + 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.fan_auto_box.setObjectName("fan_auto_box") + self.gan_layout.addWidget(self.fan_auto_box) + self.horizontalLayout_6.addLayout(self.gan_layout) + self.settings_layout.addWidget(self.fan_group) + self.line_3 = QtWidgets.QFrame(parent=self.bottom_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.line_3.sizePolicy().hasHeightForWidth()) + self.line_3.setSizePolicy(sizePolicy) + self.line_3.setFrameShape(QtWidgets.QFrame.Shape.VLine) + self.line_3.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + self.line_3.setObjectName("line_3") + self.settings_layout.addWidget(self.line_3) + self.hw_rev_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group) + self.hw_rev_lbl.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.hw_rev_lbl.sizePolicy().hasHeightForWidth()) + self.hw_rev_lbl.setSizePolicy(sizePolicy) + self.hw_rev_lbl.setMinimumSize(QtCore.QSize(150, 0)) + self.hw_rev_lbl.setMaximumSize(QtCore.QSize(150, 16777215)) + self.hw_rev_lbl.setBaseSize(QtCore.QSize(150, 0)) + self.hw_rev_lbl.setObjectName("hw_rev_lbl") + self.settings_layout.addWidget(self.hw_rev_lbl) + self.horizontalLayout_2.addLayout(self.settings_layout) + self.main_layout.addWidget(self.bottom_settings_group) + self.gridLayout_2.addLayout(self.main_layout, 0, 1, 1, 1) + MainWindow.setCentralWidget(self.main_widget) + + self.retranslateUi(MainWindow) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "Control TEC")) + self.ch1_t_graph.setTitle(_translate("MainWindow", "Channel 1 Temperature")) + self.ch0_t_graph.setTitle(_translate("MainWindow", "Channel 0 Temperature")) + self.ch0_i_graph.setTitle(_translate("MainWindow", "Channel 0 Current")) + self.ch1_i_graph.setTitle(_translate("MainWindow", "Channel 1 Current")) + self.ip_set_line.setText(_translate("MainWindow", "192.168.1.26")) + self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) + self.connect_btn.setText(_translate("MainWindow", "Connect")) + self.status_lbl.setText(_translate("MainWindow", "Disconnected")) + self.report_box.setText(_translate("MainWindow", "Report")) + self.report_apply_btn.setText(_translate("MainWindow", "Apply")) + self.fan_lbl.setText(_translate("MainWindow", "Fan:")) + self.fan_auto_box.setText(_translate("MainWindow", "Auto")) + self.hw_rev_lbl.setText(_translate("MainWindow", "Thermostat vX.Y")) +from pyqtgraph import PlotWidget +from pyqtgraph.parametertree import ParameterTree + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + MainWindow = QtWidgets.QMainWindow() + ui = Ui_MainWindow() + ui.setupUi(MainWindow) + MainWindow.show() + sys.exit(app.exec()) From 3de6f233f99f060f436a4a6eb352d204e788edce Mon Sep 17 00:00:00 2001 From: Egor Savkin Date: Fri, 19 May 2023 13:45:01 +0800 Subject: [PATCH 020/247] Create client watcher, that would poll Thermostat for config Signed-off-by: Egor Savkin --- pytec/tec_qt.py | 71 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8ee039a..93d639f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,5 +1,5 @@ from PyQt6 import QtWidgets, uic -from PyQt6.QtCore import QThread, QThreadPool, pyqtSignal, QRunnable, QObject, QSignalBlocker, pyqtSlot +from PyQt6.QtCore import QThread, QThreadPool, pyqtSignal, QRunnable, QObject, QSignalBlocker, pyqtSlot, QDeadlineTimer from pyqtgraph import PlotWidget from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType import pyqtgraph as pg @@ -18,6 +18,8 @@ ui: Ui_MainWindow = None thread_pool = QThreadPool.globalInstance() connection_watcher = None +client_watcher = None +app: QtWidgets.QApplication = None def get_argparser(): @@ -39,7 +41,7 @@ class WatchConnectTask(QThread): connecting = pyqtSignal() fan_update = pyqtSignal(object) - def __init__(self, ip, port, parent): + def __init__(self, parent, ip, port): self.ip = ip self.port = port super().__init__(parent) @@ -55,8 +57,8 @@ class WatchConnectTask(QThread): self.connecting.emit() tec_client = Client(host=self.ip, port=self.port, timeout=30) self.connected.emit(True) - self.hw_rev.emit(tec_client.hw_rev()) - self.fan_update.emit(tec_client.fan()) + thread_pool.start(ClientTask(lambda: self.hw_rev.emit(tec_client.hw_rev()))) + #thread_pool.start(ClientTask(lambda: self.fan_update.emit(tec_client.fan()))) except Exception as e: logging.error(f"Failed communicating to the {self.ip}:{self.port}: {e}") self.connected.emit(False) @@ -67,7 +69,39 @@ class WatchConnectTask(QThread): if tec_client: tec_client.disconnect() tec_client = None - self.connected.emit(False) + self.connected.emit(False) + + +class ClientWatcher(QThread): + fan_update = pyqtSignal(object) + pwm_update = pyqtSignal(object) + report_update = pyqtSignal(object) + pid_update = pyqtSignal(object) + + def __init__(self, parent, update_s): + self.update_s = update_s + self.running = True + super().__init__(parent) + + def run(self): + while self.running: + thread_pool.start(ClientTask(lambda: self.update_params())) + self.msleep(int(self.update_s * 1000)) + + def update_params(self): + self.fan_update.emit(tec_client.fan()) + + @pyqtSlot() + def stop_watching(self): + self.running = False + deadline = QDeadlineTimer() + deadline.setDeadline(100) + self.wait(deadline) + self.terminate() + + @pyqtSlot() + def set_update_s(self): + self.update_s = ui.report_refresh_spin.value() class ClientTask(QRunnable): @@ -77,16 +111,18 @@ class ClientTask(QRunnable): self.kwargs = kwargs super().__init__() - def run(self) -> None: + def run(self): try: self.func(*self.args, **self.kwargs) except (TimeoutError, OSError): logging.warning("Client connection error, disconnecting", exc_info=True) if connection_watcher: + thread_pool.clear() # clearing all next requests connection_watcher.client_disconnected() def connected(result): + global client_watcher, connection_watcher ui.graph_group.setEnabled(result) ui.hw_rev_lbl.setEnabled(result) ui.fan_group.setEnabled(result) @@ -99,6 +135,15 @@ def connected(result): if not result: ui.hw_rev_lbl.setText("Thermostat vX.Y") ui.fan_group.setStyleSheet("") + if client_watcher: + client_watcher.stop_watching() + client_watcher = None + elif client_watcher is None: + client_watcher = ClientWatcher(ui.main_widget, ui.report_refresh_spin.value()) + client_watcher.fan_update.connect(fan_update) + ui.report_apply_btn.clicked.connect(client_watcher.set_update_s) + app.aboutToQuit.connect(client_watcher.stop_watching) + client_watcher.start() def hw_rev(hw_rev_d: dict): @@ -144,25 +189,29 @@ def fan_auto_set(enabled): def connect(): global connection_watcher - connection_watcher = WatchConnectTask(ui.ip_set_line.text(), ui.port_set_spin.value(), ui.main_widget) + connection_watcher = WatchConnectTask(ui.main_widget, ui.ip_set_line.text(), ui.port_set_spin.value()) connection_watcher.connected.connect(connected) connection_watcher.connecting.connect(lambda: ui.status_lbl.setText("Connecting...")) connection_watcher.hw_rev.connect(hw_rev) connection_watcher.fan_update.connect(fan_update) connection_watcher.start() + app.aboutToQuit.connect(connection_watcher.terminate) def main(): - global ui + global ui, thread_pool, app args = get_argparser().parse_args() if args.logLevel: logging.basicConfig(level=getattr(logging, args.logLevel)) app = QtWidgets.QApplication(sys.argv) main_window = QtWidgets.QMainWindow() - #ui = Ui_MainWindow() - #ui.setupUi(main_window) - ui = uic.loadUi('tec_qt.ui', main_window) + ui = Ui_MainWindow() + ui.setupUi(main_window) + # ui = uic.loadUi('tec_qt.ui', main_window) + + thread_pool = QThreadPool(parent=ui.main_widget) + thread_pool.setMaxThreadCount(1) # avoid concurrent requests ui.connect_btn.clicked.connect(lambda _checked: connect()) ui.fan_power_slider.valueChanged.connect(fan_set) From 0ad77047f11b8dbb9feb7084ff64f370dcb9dfe1 Mon Sep 17 00:00:00 2001 From: Egor Savkin Date: Mon, 26 Jun 2023 10:20:48 +0800 Subject: [PATCH 021/247] Try move from Qthreads to qasync Signed-off-by: Egor Savkin --- flake.nix | 40 ++++++++++++++- pytec/pytec/client.py | 1 + pytec/tec_qt.py | 112 ++++++++++++++++++++++++++---------------- 3 files changed, 109 insertions(+), 44 deletions(-) diff --git a/flake.nix b/flake.nix index 485c03b..452e64d 100644 --- a/flake.nix +++ b/flake.nix @@ -55,9 +55,45 @@ dontFixup = true; }; + + qasync = pkgs.python3Packages.buildPythonPackage rec { + pname = "qasync"; + version = "0.24.0"; + src = pkgs.fetchFromGitHub { + owner = "CabbageDevelopment"; + repo = "qasync"; + rev = "v${version}"; + sha256 = "sha256-ls5F+VntXXa3n+dULaYWK9sAmwly1nk/5+RGWLrcf2Y="; + }; + propagatedBuildInputs = [ pkgs.python3Packages.pyqt6 ]; + nativeCheckInputs = [ pkgs.python3Packages.pytest ]; + checkPhase = '' + pytest -k 'test_qthreadexec.py' # the others cause the test execution to be aborted, I think because of asyncio + ''; + }; + thermostat_gui = pkgs.python3Packages.buildPythonPackage rec { + pname = "thermostat_gui"; + version = "0.0.0"; + src = self; + + preBuild = + '' + export VERSIONEER_OVERRIDE=${version} + export VERSIONEER_REV=v0.0.0 + ''; + + nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; + propagatedBuildInputs = (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync]); + + dontWrapQtApps = true; + postFixup = '' + ls -al $out/ + wrapQtApp "$out/pytec/tec_qt" + ''; + }; in { packages.x86_64-linux = { - inherit thermostat; + inherit thermostat qasync thermostat_gui; }; hydraJobs = { @@ -69,7 +105,7 @@ buildInputs = with pkgs; [ rust openocd dfu-util ] ++ (with python3Packages; [ - numpy matplotlib pyqtgraph setuptools pyqt6 + numpy matplotlib pyqtgraph setuptools pyqt6 qasync ]); shellHook= '' diff --git a/pytec/pytec/client.py b/pytec/pytec/client.py index 3a78b46..c9da63a 100644 --- a/pytec/pytec/client.py +++ b/pytec/pytec/client.py @@ -40,6 +40,7 @@ class Client: line = self._read_line() response = json.loads(line) + logging.debug(f"{command}: {response}") if "error" in response: raise CommandError(response["error"]) return response diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 93d639f..38d8215 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -6,7 +6,10 @@ import pyqtgraph as pg import sys import argparse import logging +import asyncio +import atexit from pytec.client import Client +from qasync import QEventLoop # pyuic6 -x tec_qt.ui -o ui_tec_qt.py from ui_tec_qt import Ui_MainWindow @@ -16,7 +19,7 @@ tec_client: Client = None # ui = None ui: Ui_MainWindow = None -thread_pool = QThreadPool.globalInstance() +queue = None connection_watcher = None client_watcher = None app: QtWidgets.QApplication = None @@ -35,7 +38,43 @@ def get_argparser(): return parser -class WatchConnectTask(QThread): +def wrap_client_task(func, *args, **kwargs): + loop = asyncio.get_event_loop() + task = ClientTask(func, *args, **kwargs) + asyncio.ensure_future(queue.put(task), loop=loop) + + +async def process_client_tasks(): + global queue + if queue is None: + queue = asyncio.Queue() + loop = asyncio.get_event_loop() + while True: + task = await queue.get() + await task.run() + queue.task_done() + + +class ClientTask: + def __init__(self, func, *args, **kwargs): + self.func = func + self.args = args + self.kwargs = kwargs + super().__init__() + + async def run(self): + try: + lock = asyncio.Lock() + async with lock: + self.func(*self.args, **self.kwargs) + except (TimeoutError, OSError): + logging.warning("Client connection error, disconnecting", exc_info=True) + if connection_watcher: + #thread_pool.clear() # clearing all next requests + connection_watcher.client_disconnected() + + +class WatchConnectTask(QObject): connected = pyqtSignal(bool) hw_rev = pyqtSignal(dict) connecting = pyqtSignal() @@ -57,8 +96,8 @@ class WatchConnectTask(QThread): self.connecting.emit() tec_client = Client(host=self.ip, port=self.port, timeout=30) self.connected.emit(True) - thread_pool.start(ClientTask(lambda: self.hw_rev.emit(tec_client.hw_rev()))) - #thread_pool.start(ClientTask(lambda: self.fan_update.emit(tec_client.fan()))) + wrap_client_task(lambda: self.hw_rev.emit(tec_client.hw_rev())) + # wrap_client_task(lambda: self.fan_update.emit(tec_client.fan())) except Exception as e: logging.error(f"Failed communicating to the {self.ip}:{self.port}: {e}") self.connected.emit(False) @@ -72,7 +111,8 @@ class WatchConnectTask(QThread): self.connected.emit(False) -class ClientWatcher(QThread): + +class ClientWatcher(QObject): fan_update = pyqtSignal(object) pwm_update = pyqtSignal(object) report_update = pyqtSignal(object) @@ -83,10 +123,10 @@ class ClientWatcher(QThread): self.running = True super().__init__(parent) - def run(self): + async def run(self): while self.running: - thread_pool.start(ClientTask(lambda: self.update_params())) - self.msleep(int(self.update_s * 1000)) + wrap_client_task(lambda: self.update_params()) + await asyncio.sleep(int(self.update_s * 1000)) def update_params(self): self.fan_update.emit(tec_client.fan()) @@ -94,34 +134,17 @@ class ClientWatcher(QThread): @pyqtSlot() def stop_watching(self): self.running = False - deadline = QDeadlineTimer() - deadline.setDeadline(100) - self.wait(deadline) - self.terminate() + #deadline = QDeadlineTimer() + #deadline.setDeadline(100) + #self.wait(deadline) + #self.terminate() @pyqtSlot() def set_update_s(self): self.update_s = ui.report_refresh_spin.value() -class ClientTask(QRunnable): - def __init__(self, func, *args, **kwargs): - self.func = func - self.args = args - self.kwargs = kwargs - super().__init__() - - def run(self): - try: - self.func(*self.args, **self.kwargs) - except (TimeoutError, OSError): - logging.warning("Client connection error, disconnecting", exc_info=True) - if connection_watcher: - thread_pool.clear() # clearing all next requests - connection_watcher.client_disconnected() - - -def connected(result): +def on_connection_changed(result): global client_watcher, connection_watcher ui.graph_group.setEnabled(result) ui.hw_rev_lbl.setEnabled(result) @@ -143,7 +166,7 @@ def connected(result): client_watcher.fan_update.connect(fan_update) ui.report_apply_btn.clicked.connect(client_watcher.set_update_s) app.aboutToQuit.connect(client_watcher.stop_watching) - client_watcher.start() + wrap_client_task(client_watcher.run) def hw_rev(hw_rev_d: dict): @@ -173,7 +196,7 @@ def fan_set(): global tec_client if tec_client is None or ui.fan_auto_box.isChecked(): return - thread_pool.start(ClientTask(lambda: tec_client.set_param("fan", ui.fan_power_slider.value()))) + wrap_client_task(lambda: tec_client.set_param("fan", ui.fan_power_slider.value())) def fan_auto_set(enabled): @@ -182,37 +205,41 @@ def fan_auto_set(enabled): return ui.fan_power_slider.setEnabled(not enabled) if enabled: - thread_pool.start(ClientTask(lambda: tec_client.set_param("fan", "auto"))) + wrap_client_task(lambda: tec_client.set_param("fan", "auto")) else: - thread_pool.start(ClientTask(lambda: tec_client.set_param("fan", ui.fan_power_slider.value()))) + wrap_client_task(lambda: tec_client.set_param("fan", ui.fan_power_slider.value())) def connect(): global connection_watcher connection_watcher = WatchConnectTask(ui.main_widget, ui.ip_set_line.text(), ui.port_set_spin.value()) - connection_watcher.connected.connect(connected) + connection_watcher.connected.connect(on_connection_changed) connection_watcher.connecting.connect(lambda: ui.status_lbl.setText("Connecting...")) connection_watcher.hw_rev.connect(hw_rev) connection_watcher.fan_update.connect(fan_update) - connection_watcher.start() - app.aboutToQuit.connect(connection_watcher.terminate) + wrap_client_task(connection_watcher.run) + #app.aboutToQuit.connect(connection_watcher.terminate) def main(): - global ui, thread_pool, app + global ui, app, queue args = get_argparser().parse_args() if args.logLevel: logging.basicConfig(level=getattr(logging, args.logLevel)) app = QtWidgets.QApplication(sys.argv) + + loop = QEventLoop(app) + asyncio.set_event_loop(loop) + atexit.register(loop.close) + + loop.create_task(process_client_tasks()) + main_window = QtWidgets.QMainWindow() ui = Ui_MainWindow() ui.setupUi(main_window) # ui = uic.loadUi('tec_qt.ui', main_window) - thread_pool = QThreadPool(parent=ui.main_widget) - thread_pool.setMaxThreadCount(1) # avoid concurrent requests - ui.connect_btn.clicked.connect(lambda _checked: connect()) ui.fan_power_slider.valueChanged.connect(fan_set) ui.fan_auto_box.stateChanged.connect(fan_auto_set) @@ -225,7 +252,8 @@ def main(): ui.connect_btn.click() main_window.show() - sys.exit(app.exec()) + + loop.run_until_complete(app.exec()) if __name__ == '__main__': From f546a3c61b22148504eea0467d150e9f56d9afbe Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 27 Jun 2023 17:34:39 +0800 Subject: [PATCH 022/247] Finish moving over to qasync Also: -Add aioclient The old client is synchronous and blocking, and the only way to achieve true asynchronous IO is to create a new client that interfaces with asyncio. -Make the GUI `nix run`-able --- flake.nix | 44 ++++------ pytec/aioexample.py | 16 ++++ pytec/pytec/aioclient.py | 181 +++++++++++++++++++++++++++++++++++++++ pytec/setup.py | 6 ++ pytec/tec_qt.py | 176 ++++++++++++------------------------- 5 files changed, 277 insertions(+), 146 deletions(-) create mode 100644 pytec/aioexample.py create mode 100644 pytec/pytec/aioclient.py diff --git a/flake.nix b/flake.nix index 452e64d..ccc5ad8 100644 --- a/flake.nix +++ b/flake.nix @@ -58,42 +58,37 @@ qasync = pkgs.python3Packages.buildPythonPackage rec { pname = "qasync"; - version = "0.24.0"; - src = pkgs.fetchFromGitHub { - owner = "CabbageDevelopment"; - repo = "qasync"; - rev = "v${version}"; - sha256 = "sha256-ls5F+VntXXa3n+dULaYWK9sAmwly1nk/5+RGWLrcf2Y="; + version = "0.27.1"; + format = "pyproject"; + src = pkgs.fetchPypi { + inherit pname version; + sha256 = "sha256-jcdo/R7l3hBEx8MF7M8tOdJNh4A+pxGJ1AJPtHX0mF8="; }; + buildInputs = [ pkgs.python3Packages.poetry-core ]; propagatedBuildInputs = [ pkgs.python3Packages.pyqt6 ]; - nativeCheckInputs = [ pkgs.python3Packages.pytest ]; - checkPhase = '' - pytest -k 'test_qthreadexec.py' # the others cause the test execution to be aborted, I think because of asyncio - ''; }; - thermostat_gui = pkgs.python3Packages.buildPythonPackage rec { + + thermostat_gui = pkgs.python3Packages.buildPythonPackage { pname = "thermostat_gui"; version = "0.0.0"; - src = self; - - preBuild = - '' - export VERSIONEER_OVERRIDE=${version} - export VERSIONEER_REV=v0.0.0 - ''; + src = "${self}/pytec"; nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; - propagatedBuildInputs = (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync]); + propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync ]); dontWrapQtApps = true; postFixup = '' - ls -al $out/ - wrapQtApp "$out/pytec/tec_qt" + wrapQtApp "$out/bin/tec_qt" ''; }; in { packages.x86_64-linux = { - inherit thermostat qasync thermostat_gui; + inherit thermostat thermostat_gui; + }; + + apps.x86_64-linux.thermostat_gui = { + type = "app"; + program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt"; }; hydraJobs = { @@ -107,11 +102,6 @@ ] ++ (with python3Packages; [ numpy matplotlib pyqtgraph setuptools pyqt6 qasync ]); - shellHook= - '' - export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.dev.qtPluginPrefix} - export QML2_IMPORT_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.dev.qtQmlPrefix} - ''; }; defaultPackage.x86_64-linux = thermostat; }; diff --git a/pytec/aioexample.py b/pytec/aioexample.py new file mode 100644 index 0000000..2214764 --- /dev/null +++ b/pytec/aioexample.py @@ -0,0 +1,16 @@ +import asyncio +from pytec.aioclient import Client + +async def main(): + tec = Client() + await tec.connect() #(host="192.168.1.26", port=23) + await tec.set_param("s-h", 1, "t0", 20) + print(await tec.get_pwm()) + print(await tec.get_pid()) + print(await tec.get_pwm()) + print(await tec.get_postfilter()) + print(await tec.get_steinhart_hart()) + async for data in tec.report_mode(): + print(data) + +asyncio.run(main()) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py new file mode 100644 index 0000000..b67d15f --- /dev/null +++ b/pytec/pytec/aioclient.py @@ -0,0 +1,181 @@ +import asyncio +import json +import logging + +class CommandError(Exception): + pass + +class Client: + def __init__(self): + self._reader = None + self._writer = None + self._command_lock = asyncio.Lock() + + async def connect(self, host='192.168.1.26', port=23, timeout=None): + self._reader, self._writer = await asyncio.open_connection(host, port) + await self._check_zero_limits() + + async def disconnect(self): + self._writer.close() + await self._writer.wait_closed() + + async def _check_zero_limits(self): + pwm_report = await self.get_pwm() + for pwm_channel in pwm_report: + for limit in ["max_i_neg", "max_i_pos", "max_v"]: + if pwm_channel[limit]["value"] == 0.0: + logging.warning("`{}` limit is set to zero on channel {}".format(limit, pwm_channel["channel"])) + + async def _read_line(self): + # read 1 line + chunk = await self._reader.readline() + return chunk.decode('utf-8', errors='ignore') + + async def _command(self, *command): + async with self._command_lock: + self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8')) + await self._writer.drain() + + line = await self._read_line() + + response = json.loads(line) + logging.debug(f"{command}: {response}") + if "error" in response: + raise CommandError(response["error"]) + return response + + async def _get_conf(self, topic): + result = [None, None] + for item in await self._command(topic): + result[int(item["channel"])] = item + return result + + async def get_pwm(self): + """Retrieve PWM limits for the TEC + + Example:: + [{'channel': 0, + 'center': 'vref', + 'i_set': {'max': 2.9802790335151985, 'value': -0.02002179650216762}, + 'max_i_neg': {'max': 3.0, 'value': 3.0}, + 'max_v': {'max': 5.988, 'value': 5.988}, + 'max_i_pos': {'max': 3.0, 'value': 3.0}}, + {'channel': 1, + 'center': 'vref', + 'i_set': {'max': 2.9802790335151985, 'value': -0.02002179650216762}, + 'max_i_neg': {'max': 3.0, 'value': 3.0}, + 'max_v': {'max': 5.988, 'value': 5.988}, + 'max_i_pos': {'max': 3.0, 'value': 3.0}} + ] + """ + return await self._get_conf("pwm") + + async def get_pid(self): + """Retrieve PID control state + + Example:: + [{'channel': 0, + 'parameters': { + 'kp': 10.0, + 'ki': 0.02, + 'kd': 0.0, + 'output_min': 0.0, + 'output_max': 3.0}, + 'target': 37.0}, + {'channel': 1, + 'parameters': { + 'kp': 10.0, + 'ki': 0.02, + 'kd': 0.0, + 'output_min': 0.0, + 'output_max': 3.0}, + 'target': 36.5}] + """ + return await self._get_conf("pid") + + async def get_steinhart_hart(self): + """Retrieve Steinhart-Hart parameters for resistance to temperature conversion + + Example:: + [{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0}, + {'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}] + """ + return await self._get_conf("s-h") + + async def get_postfilter(self): + """Retrieve DAC postfilter configuration + + Example:: + [{'rate': None, 'channel': 0}, + {'rate': 21.25, 'channel': 1}] + """ + return await self._get_conf("postfilter") + + async def report_mode(self): + """Start reporting measurement values + + Example of yielded data:: + {'channel': 0, + 'time': 2302524, + 'adc': 0.6199188965423515, + 'sens': 6138.519310282602, + 'temperature': 36.87032392655527, + 'pid_engaged': True, + 'i_set': 2.0635816680889123, + 'vref': 1.494, + 'dac_value': 2.527790834044456, + 'dac_feedback': 2.523, + 'i_tec': 2.331, + 'tec_i': 2.0925, + 'tec_u_meas': 2.5340000000000003, + 'pid_output': 2.067581958092247} + """ + await self._command("report mode", "on") + + while True: + line = await self._read_line() + if not line: + break + try: + yield json.loads(line) + except json.decoder.JSONDecodeError: + pass + + async def set_param(self, topic, channel, field="", value=""): + """Set configuration parameters + + Examples:: + tec.set_param("pwm", 0, "max_v", 2.0) + tec.set_param("pid", 1, "output_max", 2.5) + tec.set_param("s-h", 0, "t0", 20.0) + tec.set_param("center", 0, "vref") + tec.set_param("postfilter", 1, 21) + + See the firmware's README.md for a full list. + """ + if type(value) is float: + value = "{:f}".format(value) + if type(value) is not str: + value = str(value) + await self._command(topic, str(channel), field, value) + + async def power_up(self, channel, target): + """Start closed-loop mode""" + await self.set_param("pid", channel, "target", value=target) + await self.set_param("pwm", channel, "pid") + + async def save_config(self): + """Save current configuration to EEPROM""" + await self._command("save") + + async def load_config(self): + """Load current configuration from EEPROM""" + await self._command("load") + + async def hw_rev(self): + """Get Thermostat hardware revision""" + return await self._command("hwrev") + + async def fan(self): + """Get Thermostat current fan settings""" + return await self._command("fan") diff --git a/pytec/setup.py b/pytec/setup.py index 3a46a57..c084cdf 100644 --- a/pytec/setup.py +++ b/pytec/setup.py @@ -9,4 +9,10 @@ setup( license="GPLv3", install_requires=["setuptools"], packages=find_packages(), + entry_points={ + "gui_scripts": [ + "tec_qt = tec_qt:main", + ] + }, + py_modules=['tec_qt', 'ui_tec_qt'], ) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 38d8215..86a1e2d 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,5 +1,5 @@ from PyQt6 import QtWidgets, uic -from PyQt6.QtCore import QThread, QThreadPool, pyqtSignal, QRunnable, QObject, QSignalBlocker, pyqtSlot, QDeadlineTimer +from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot from pyqtgraph import PlotWidget from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType import pyqtgraph as pg @@ -7,9 +7,9 @@ import sys import argparse import logging import asyncio -import atexit -from pytec.client import Client -from qasync import QEventLoop +from pytec.aioclient import Client +import qasync +from qasync import asyncSlot, asyncClose # pyuic6 -x tec_qt.ui -o ui_tec_qt.py from ui_tec_qt import Ui_MainWindow @@ -19,9 +19,8 @@ tec_client: Client = None # ui = None ui: Ui_MainWindow = None -queue = None -connection_watcher = None client_watcher = None +client_watcher_task = None app: QtWidgets.QApplication = None @@ -38,80 +37,6 @@ def get_argparser(): return parser -def wrap_client_task(func, *args, **kwargs): - loop = asyncio.get_event_loop() - task = ClientTask(func, *args, **kwargs) - asyncio.ensure_future(queue.put(task), loop=loop) - - -async def process_client_tasks(): - global queue - if queue is None: - queue = asyncio.Queue() - loop = asyncio.get_event_loop() - while True: - task = await queue.get() - await task.run() - queue.task_done() - - -class ClientTask: - def __init__(self, func, *args, **kwargs): - self.func = func - self.args = args - self.kwargs = kwargs - super().__init__() - - async def run(self): - try: - lock = asyncio.Lock() - async with lock: - self.func(*self.args, **self.kwargs) - except (TimeoutError, OSError): - logging.warning("Client connection error, disconnecting", exc_info=True) - if connection_watcher: - #thread_pool.clear() # clearing all next requests - connection_watcher.client_disconnected() - - -class WatchConnectTask(QObject): - connected = pyqtSignal(bool) - hw_rev = pyqtSignal(dict) - connecting = pyqtSignal() - fan_update = pyqtSignal(object) - - def __init__(self, parent, ip, port): - self.ip = ip - self.port = port - super().__init__(parent) - - def run(self): - global tec_client - try: - if tec_client: - tec_client.disconnect() - tec_client = None - self.connected.emit(False) - else: - self.connecting.emit() - tec_client = Client(host=self.ip, port=self.port, timeout=30) - self.connected.emit(True) - wrap_client_task(lambda: self.hw_rev.emit(tec_client.hw_rev())) - # wrap_client_task(lambda: self.fan_update.emit(tec_client.fan())) - except Exception as e: - logging.error(f"Failed communicating to the {self.ip}:{self.port}: {e}") - self.connected.emit(False) - - @pyqtSlot() - def client_disconnected(self): - global tec_client - if tec_client: - tec_client.disconnect() - tec_client = None - self.connected.emit(False) - - - class ClientWatcher(QObject): fan_update = pyqtSignal(object) pwm_update = pyqtSignal(object) @@ -125,19 +50,15 @@ class ClientWatcher(QObject): async def run(self): while self.running: - wrap_client_task(lambda: self.update_params()) - await asyncio.sleep(int(self.update_s * 1000)) + await self.update_params() + await asyncio.sleep(self.update_s) - def update_params(self): - self.fan_update.emit(tec_client.fan()) + async def update_params(self): + self.fan_update.emit(await tec_client.fan()) @pyqtSlot() def stop_watching(self): self.running = False - #deadline = QDeadlineTimer() - #deadline.setDeadline(100) - #self.wait(deadline) - #self.terminate() @pyqtSlot() def set_update_s(self): @@ -145,7 +66,7 @@ class ClientWatcher(QObject): def on_connection_changed(result): - global client_watcher, connection_watcher + global client_watcher, client_watcher_task ui.graph_group.setEnabled(result) ui.hw_rev_lbl.setEnabled(result) ui.fan_group.setEnabled(result) @@ -161,12 +82,7 @@ def on_connection_changed(result): if client_watcher: client_watcher.stop_watching() client_watcher = None - elif client_watcher is None: - client_watcher = ClientWatcher(ui.main_widget, ui.report_refresh_spin.value()) - client_watcher.fan_update.connect(fan_update) - ui.report_apply_btn.clicked.connect(client_watcher.set_update_s) - app.aboutToQuit.connect(client_watcher.stop_watching) - wrap_client_task(client_watcher.run) + client_watcher_task = None def hw_rev(hw_rev_d: dict): @@ -192,55 +108,73 @@ def fan_update(fan_settings): ui.fan_auto_box.setChecked(fan_settings["auto_mode"]) -def fan_set(): +@asyncSlot() +async def fan_set(_): global tec_client if tec_client is None or ui.fan_auto_box.isChecked(): return - wrap_client_task(lambda: tec_client.set_param("fan", ui.fan_power_slider.value())) + await tec_client.set_param("fan", ui.fan_power_slider.value()) -def fan_auto_set(enabled): +@asyncSlot() +async def fan_auto_set(enabled): global tec_client if tec_client is None: return ui.fan_power_slider.setEnabled(not enabled) if enabled: - wrap_client_task(lambda: tec_client.set_param("fan", "auto")) + await tec_client.set_param("fan", "auto") else: - wrap_client_task(lambda: tec_client.set_param("fan", ui.fan_power_slider.value())) + await tec_client.set_param("fan", ui.fan_power_slider.value()) -def connect(): - global connection_watcher - connection_watcher = WatchConnectTask(ui.main_widget, ui.ip_set_line.text(), ui.port_set_spin.value()) - connection_watcher.connected.connect(on_connection_changed) - connection_watcher.connecting.connect(lambda: ui.status_lbl.setText("Connecting...")) - connection_watcher.hw_rev.connect(hw_rev) - connection_watcher.fan_update.connect(fan_update) - wrap_client_task(connection_watcher.run) - #app.aboutToQuit.connect(connection_watcher.terminate) +@asyncSlot() +async def connect(_): + global tec_client, client_watcher, client_watcher_task + ip, port = ui.ip_set_line.text(), ui.port_set_spin.value() + try: + if tec_client: + await tec_client.disconnect() + tec_client = None + on_connection_changed(False) + else: + ui.status_lbl.setText("Connecting...") + tec_client = Client() + await tec_client.connect(host=ip, port=port, timeout=30) + on_connection_changed(True) + hw_rev(await tec_client.hw_rev()) + # fan_update(await tec_client.fan()) + if client_watcher is None: + client_watcher = ClientWatcher(ui.main_widget, ui.report_refresh_spin.value()) + client_watcher.fan_update.connect(fan_update) + ui.report_apply_btn.clicked.connect( + lambda: client_watcher.set_update_s(ui.report_refresh_spin.value()) + ) + app.aboutToQuit.connect(client_watcher.stop_watching) + client_watcher_task = asyncio.create_task(client_watcher.run()) + except Exception as e: + logging.error(f"Failed communicating to the {ip}:{port}: {e}") + on_connection_changed(False) -def main(): - global ui, app, queue +async def coro_main(): + global ui, app + args = get_argparser().parse_args() if args.logLevel: logging.basicConfig(level=getattr(logging, args.logLevel)) - app = QtWidgets.QApplication(sys.argv) + app_quit_event = asyncio.Event() - loop = QEventLoop(app) - asyncio.set_event_loop(loop) - atexit.register(loop.close) - - loop.create_task(process_client_tasks()) + app = QtWidgets.QApplication.instance() + app.aboutToQuit.connect(app_quit_event.set) main_window = QtWidgets.QMainWindow() ui = Ui_MainWindow() ui.setupUi(main_window) # ui = uic.loadUi('tec_qt.ui', main_window) - ui.connect_btn.clicked.connect(lambda _checked: connect()) + ui.connect_btn.clicked.connect(connect) ui.fan_power_slider.valueChanged.connect(fan_set) ui.fan_auto_box.stateChanged.connect(fan_auto_set) @@ -253,7 +187,11 @@ def main(): main_window.show() - loop.run_until_complete(app.exec()) + await app_quit_event.wait() + + +def main(): + qasync.run(coro_main()) if __name__ == '__main__': From f469d8fee389989fd96b0d158116194bcda60b59 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 8 Aug 2023 17:16:11 +0800 Subject: [PATCH 023/247] Stop polling drift Just waiting for the update_s doesn't take into account the time to execute update_params, and causes time drift. --- pytec/tec_qt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 86a1e2d..eb15089 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -49,9 +49,11 @@ class ClientWatcher(QObject): super().__init__(parent) async def run(self): + loop = asyncio.get_running_loop() while self.running: + time = loop.time() await self.update_params() - await asyncio.sleep(self.update_s) + await asyncio.sleep(self.update_s - (loop.time() - time)) async def update_params(self): self.fan_update.emit(await tec_client.fan()) From 73887564a5a530d378edf04ad18a5bcecdb56e1e Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 11 Aug 2023 17:41:31 +0800 Subject: [PATCH 024/247] Change title --- pytec/tec_qt.ui | 2 +- pytec/ui_tec_qt.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 58f5849..072befa 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -23,7 +23,7 @@ - Control TEC + Thermostat Control Panel diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index b26d878..9e502d5 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -295,7 +295,7 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "Control TEC")) + MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel")) self.ch1_t_graph.setTitle(_translate("MainWindow", "Channel 1 Temperature")) self.ch0_t_graph.setTitle(_translate("MainWindow", "Channel 0 Temperature")) self.ch0_i_graph.setTitle(_translate("MainWindow", "Channel 0 Current")) From 299ef7dcc3c0d54221aa7a877589735f94a2560c Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 30 Jun 2023 11:27:31 +0800 Subject: [PATCH 025/247] Get rid of app global QApplication is a singleton, no need for global --- pytec/tec_qt.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index eb15089..60d9fa7 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -21,7 +21,6 @@ ui: Ui_MainWindow = None client_watcher = None client_watcher_task = None -app: QtWidgets.QApplication = None def get_argparser(): @@ -152,7 +151,7 @@ async def connect(_): ui.report_apply_btn.clicked.connect( lambda: client_watcher.set_update_s(ui.report_refresh_spin.value()) ) - app.aboutToQuit.connect(client_watcher.stop_watching) + QtWidgets.QApplication.instance().aboutToQuit.connect(client_watcher.stop_watching) client_watcher_task = asyncio.create_task(client_watcher.run()) except Exception as e: logging.error(f"Failed communicating to the {ip}:{port}: {e}") @@ -160,7 +159,7 @@ async def connect(_): async def coro_main(): - global ui, app + global ui args = get_argparser().parse_args() if args.logLevel: From c6ca2b3490b956c607fea02aed5b555a9085df6e Mon Sep 17 00:00:00 2001 From: atse Date: Sat, 1 Jul 2023 23:46:40 +0800 Subject: [PATCH 026/247] Make Ui_MainWindow a superclass of our main window Gets rid of the global ui. --- pytec/tec_qt.py | 208 +++++++++++++++++++++++------------------------- 1 file changed, 101 insertions(+), 107 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 60d9fa7..28f1921 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,4 +1,4 @@ -from PyQt6 import QtWidgets, uic +from PyQt6 import QtWidgets from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot from pyqtgraph import PlotWidget from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType @@ -16,11 +16,7 @@ from ui_tec_qt import Ui_MainWindow tec_client: Client = None -# ui = None -ui: Ui_MainWindow = None - client_watcher = None -client_watcher_task = None def get_argparser(): @@ -62,105 +58,118 @@ class ClientWatcher(QObject): self.running = False @pyqtSlot() - def set_update_s(self): - self.update_s = ui.report_refresh_spin.value() + def set_update_s(self, update_s): + self.update_s = update_s -def on_connection_changed(result): - global client_watcher, client_watcher_task - ui.graph_group.setEnabled(result) - ui.hw_rev_lbl.setEnabled(result) - ui.fan_group.setEnabled(result) - ui.report_group.setEnabled(result) +class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): + def __init__(self, args): + super().__init__() - ui.ip_set_line.setEnabled(not result) - ui.port_set_spin.setEnabled(not result) - ui.status_lbl.setText("Connected" if result else "Disconnected") - ui.connect_btn.setText("Disconnect" if result else "Connect") - if not result: - ui.hw_rev_lbl.setText("Thermostat vX.Y") - ui.fan_group.setStyleSheet("") - if client_watcher: - client_watcher.stop_watching() - client_watcher = None - client_watcher_task = None + self.setupUi(self) + self.connect_btn.clicked.connect(self.connect) + self.fan_power_slider.valueChanged.connect(self.fan_set) + self.fan_auto_box.stateChanged.connect(self.fan_auto_set) -def hw_rev(hw_rev_d: dict): - logging.debug(hw_rev_d) - ui.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['major']}") - ui.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"]) - if hw_rev_d["settings"]["fan_pwm_recommended"]: - ui.fan_group.setStyleSheet("") - ui.fan_group.setToolTip("") - else: - ui.fan_group.setStyleSheet("background-color: yellow") - ui.fan_group.setToolTip("Changing the fan settings of not recommended") + self.client_watcher_task = None + if args.connect: + if args.IP: + self.ip_set_line.setText(args.IP) + if args.PORT: + self.port_set_spin.setValue(int(args.PORT)) + self.connect_btn.click() -def fan_update(fan_settings): - logging.debug(fan_settings) - if fan_settings is None: - return - with QSignalBlocker(ui.fan_power_slider) as _: - ui.fan_power_slider.setValue(fan_settings["fan_pwm"]) - ui.fan_power_slider.setEnabled(not fan_settings["auto_mode"]) - with QSignalBlocker(ui.fan_auto_box) as _: - ui.fan_auto_box.setChecked(fan_settings["auto_mode"]) + def _on_connection_changed(self, result): + global client_watcher + self.graph_group.setEnabled(result) + self.hw_rev_lbl.setEnabled(result) + self.fan_group.setEnabled(result) + self.report_group.setEnabled(result) + self.ip_set_line.setEnabled(not result) + self.port_set_spin.setEnabled(not result) + self.status_lbl.setText("Connected" if result else "Disconnected") + self.connect_btn.setText("Disconnect" if result else "Connect") + if not result: + self.hw_rev_lbl.setText("Thermostat vX.Y") + self.fan_group.setStyleSheet("") + if client_watcher: + client_watcher.stop_watching() + client_watcher = None + self.client_watcher_task = None -@asyncSlot() -async def fan_set(_): - global tec_client - if tec_client is None or ui.fan_auto_box.isChecked(): - return - await tec_client.set_param("fan", ui.fan_power_slider.value()) - - -@asyncSlot() -async def fan_auto_set(enabled): - global tec_client - if tec_client is None: - return - ui.fan_power_slider.setEnabled(not enabled) - if enabled: - await tec_client.set_param("fan", "auto") - else: - await tec_client.set_param("fan", ui.fan_power_slider.value()) - - -@asyncSlot() -async def connect(_): - global tec_client, client_watcher, client_watcher_task - ip, port = ui.ip_set_line.text(), ui.port_set_spin.value() - try: - if tec_client: - await tec_client.disconnect() - tec_client = None - on_connection_changed(False) + def _hw_rev(self, hw_rev_d: dict): + logging.debug(hw_rev_d) + self.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['major']}") + self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"]) + if hw_rev_d["settings"]["fan_pwm_recommended"]: + self.fan_group.setStyleSheet("") + self.fan_group.setToolTip("") else: - ui.status_lbl.setText("Connecting...") - tec_client = Client() - await tec_client.connect(host=ip, port=port, timeout=30) - on_connection_changed(True) - hw_rev(await tec_client.hw_rev()) - # fan_update(await tec_client.fan()) - if client_watcher is None: - client_watcher = ClientWatcher(ui.main_widget, ui.report_refresh_spin.value()) - client_watcher.fan_update.connect(fan_update) - ui.report_apply_btn.clicked.connect( - lambda: client_watcher.set_update_s(ui.report_refresh_spin.value()) - ) - QtWidgets.QApplication.instance().aboutToQuit.connect(client_watcher.stop_watching) - client_watcher_task = asyncio.create_task(client_watcher.run()) - except Exception as e: - logging.error(f"Failed communicating to the {ip}:{port}: {e}") - on_connection_changed(False) + self.fan_group.setStyleSheet("background-color: yellow") + self.fan_group.setToolTip("Changing the fan settings of not recommended") + + def fan_update(self, fan_settings): + logging.debug(fan_settings) + if fan_settings is None: + return + with QSignalBlocker(self.fan_power_slider) as _: + self.fan_power_slider.setValue(fan_settings["fan_pwm"]) + self.fan_power_slider.setEnabled(not fan_settings["auto_mode"]) + with QSignalBlocker(self.fan_auto_box) as _: + self.fan_auto_box.setChecked(fan_settings["auto_mode"]) + + @asyncSlot() + async def fan_set(self): + global tec_client + if tec_client is None or self.fan_auto_box.isChecked(): + return + await tec_client.set_param("fan", self.fan_power_slider.value()) + + @asyncSlot(int) + async def fan_auto_set(self, enabled): + global tec_client + if tec_client is None: + return + self.fan_power_slider.setEnabled(not enabled) + if enabled: + await tec_client.set_param("fan", "auto") + else: + await tec_client.set_param("fan", self.fan_power_slider.value()) + + @asyncSlot() + async def connect(self): + global tec_client, client_watcher + + ip, port = self.ip_set_line.text(), self.port_set_spin.value() + try: + if tec_client: + await tec_client.disconnect() + tec_client = None + self._on_connection_changed(False) + else: + self.status_lbl.setText("Connecting...") + tec_client = Client() + await tec_client.connect(host=ip, port=port, timeout=30) + self._on_connection_changed(True) + self._hw_rev(await tec_client.hw_rev()) + # self.fan_update(await tec_client.fan()) + if client_watcher is None: + client_watcher = ClientWatcher(self.main_widget, self.report_refresh_spin.value()) + client_watcher.fan_update.connect(self.fan_update) + self.report_apply_btn.clicked.connect( + lambda: client_watcher.set_update_s(self.report_refresh_spin.value()) + ) + QtWidgets.QApplication.instance().aboutToQuit.connect(client_watcher.stop_watching) + self.client_watcher_task = asyncio.create_task(client_watcher.run()) + except Exception as e: + logging.error(f"Failed communicating to the {ip}:{port}: {e}") + self._on_connection_changed(False) async def coro_main(): - global ui - args = get_argparser().parse_args() if args.logLevel: logging.basicConfig(level=getattr(logging, args.logLevel)) @@ -170,22 +179,7 @@ async def coro_main(): app = QtWidgets.QApplication.instance() app.aboutToQuit.connect(app_quit_event.set) - main_window = QtWidgets.QMainWindow() - ui = Ui_MainWindow() - ui.setupUi(main_window) - # ui = uic.loadUi('tec_qt.ui', main_window) - - ui.connect_btn.clicked.connect(connect) - ui.fan_power_slider.valueChanged.connect(fan_set) - ui.fan_auto_box.stateChanged.connect(fan_auto_set) - - if args.connect: - if args.IP: - ui.ip_set_line.setText(args.IP) - if args.PORT: - ui.port_set_spin.setValue(int(args.PORT)) - ui.connect_btn.click() - + main_window = MainWindow(args) main_window.show() await app_quit_event.wait() From 0252c7b0e4d13ad9f263637b2264a7c2aedd1908 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 5 Jul 2023 10:24:36 +0800 Subject: [PATCH 027/247] Invert logic, connect first --- pytec/tec_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 28f1921..81a0af2 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -145,11 +145,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ip, port = self.ip_set_line.text(), self.port_set_spin.value() try: - if tec_client: - await tec_client.disconnect() - tec_client = None - self._on_connection_changed(False) - else: + if tec_client is None: self.status_lbl.setText("Connecting...") tec_client = Client() await tec_client.connect(host=ip, port=port, timeout=30) @@ -164,6 +160,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) QtWidgets.QApplication.instance().aboutToQuit.connect(client_watcher.stop_watching) self.client_watcher_task = asyncio.create_task(client_watcher.run()) + else: + await tec_client.disconnect() + tec_client = None + self._on_connection_changed(False) except Exception as e: logging.error(f"Failed communicating to the {ip}:{port}: {e}") self._on_connection_changed(False) From 9cf33abe0657b0c5208badffe8df48febdb28685 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 5 Jul 2023 13:13:54 +0800 Subject: [PATCH 028/247] Gather client_watcher managment into connect --- pytec/tec_qt.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 81a0af2..4d3cb68 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -82,7 +82,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connect_btn.click() def _on_connection_changed(self, result): - global client_watcher self.graph_group.setEnabled(result) self.hw_rev_lbl.setEnabled(result) self.fan_group.setEnabled(result) @@ -95,10 +94,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if not result: self.hw_rev_lbl.setText("Thermostat vX.Y") self.fan_group.setStyleSheet("") - if client_watcher: - client_watcher.stop_watching() - client_watcher = None - self.client_watcher_task = None def _hw_rev(self, hw_rev_d: dict): logging.debug(hw_rev_d) @@ -152,18 +147,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._on_connection_changed(True) self._hw_rev(await tec_client.hw_rev()) # self.fan_update(await tec_client.fan()) - if client_watcher is None: - client_watcher = ClientWatcher(self.main_widget, self.report_refresh_spin.value()) - client_watcher.fan_update.connect(self.fan_update) - self.report_apply_btn.clicked.connect( - lambda: client_watcher.set_update_s(self.report_refresh_spin.value()) - ) - QtWidgets.QApplication.instance().aboutToQuit.connect(client_watcher.stop_watching) - self.client_watcher_task = asyncio.create_task(client_watcher.run()) + + client_watcher = ClientWatcher(self.main_widget, self.report_refresh_spin.value()) + client_watcher.fan_update.connect(self.fan_update) + self.report_apply_btn.clicked.connect( + lambda: client_watcher.set_update_s(self.report_refresh_spin.value()) + ) + QtWidgets.QApplication.instance().aboutToQuit.connect(client_watcher.stop_watching) + self.client_watcher_task = asyncio.create_task(client_watcher.run()) else: await tec_client.disconnect() tec_client = None self._on_connection_changed(False) + + client_watcher.stop_watching() + client_watcher = None + await self.client_watcher_task + self.client_watcher_task = None except Exception as e: logging.error(f"Failed communicating to the {ip}:{port}: {e}") self._on_connection_changed(False) From e33f8430f2e2b753f375dd7f8fb2fa5e9d357281 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 5 Jul 2023 16:02:35 +0800 Subject: [PATCH 029/247] Remove client_watcher global --- pytec/tec_qt.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 4d3cb68..8802656 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -16,8 +16,6 @@ from ui_tec_qt import Ui_MainWindow tec_client: Client = None -client_watcher = None - def get_argparser(): parser = argparse.ArgumentParser(description="ARTIQ master") @@ -72,6 +70,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) + self.client_watcher: ClientWatcher = None self.client_watcher_task = None if args.connect: @@ -136,7 +135,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot() async def connect(self): - global tec_client, client_watcher + global tec_client ip, port = self.ip_set_line.text(), self.port_set_spin.value() try: @@ -148,20 +147,20 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._hw_rev(await tec_client.hw_rev()) # self.fan_update(await tec_client.fan()) - client_watcher = ClientWatcher(self.main_widget, self.report_refresh_spin.value()) - client_watcher.fan_update.connect(self.fan_update) + self.client_watcher = ClientWatcher(self.main_widget, self.report_refresh_spin.value()) + self.client_watcher.fan_update.connect(self.fan_update) self.report_apply_btn.clicked.connect( - lambda: client_watcher.set_update_s(self.report_refresh_spin.value()) + lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) - QtWidgets.QApplication.instance().aboutToQuit.connect(client_watcher.stop_watching) - self.client_watcher_task = asyncio.create_task(client_watcher.run()) + QtWidgets.QApplication.instance().aboutToQuit.connect(self.client_watcher.stop_watching) + self.client_watcher_task = asyncio.create_task(self.client_watcher.run()) else: await tec_client.disconnect() tec_client = None self._on_connection_changed(False) - client_watcher.stop_watching() - client_watcher = None + self.client_watcher.stop_watching() + self.client_watcher = None await self.client_watcher_task self.client_watcher_task = None except Exception as e: From 3544f1ebdff763c0690499f5b7ffc3839e68e3d9 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 5 Jul 2023 16:25:13 +0800 Subject: [PATCH 030/247] Get rid of global client --- pytec/tec_qt.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8802656..e582053 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -14,8 +14,6 @@ from qasync import asyncSlot, asyncClose # pyuic6 -x tec_qt.ui -o ui_tec_qt.py from ui_tec_qt import Ui_MainWindow -tec_client: Client = None - def get_argparser(): parser = argparse.ArgumentParser(description="ARTIQ master") @@ -36,9 +34,10 @@ class ClientWatcher(QObject): report_update = pyqtSignal(object) pid_update = pyqtSignal(object) - def __init__(self, parent, update_s): + def __init__(self, parent, client, update_s): self.update_s = update_s self.running = True + self.client = client super().__init__(parent) async def run(self): @@ -49,7 +48,7 @@ class ClientWatcher(QObject): await asyncio.sleep(self.update_s - (loop.time() - time)) async def update_params(self): - self.fan_update.emit(await tec_client.fan()) + self.fan_update.emit(await self.client.fan()) @pyqtSlot() def stop_watching(self): @@ -70,6 +69,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) + self.tec_client: Client = None self.client_watcher: ClientWatcher = None self.client_watcher_task = None @@ -117,37 +117,33 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot() async def fan_set(self): - global tec_client - if tec_client is None or self.fan_auto_box.isChecked(): + if self.tec_client is None or self.fan_auto_box.isChecked(): return - await tec_client.set_param("fan", self.fan_power_slider.value()) + await self.tec_client.set_param("fan", self.fan_power_slider.value()) @asyncSlot(int) async def fan_auto_set(self, enabled): - global tec_client - if tec_client is None: + if self.tec_client is None: return self.fan_power_slider.setEnabled(not enabled) if enabled: - await tec_client.set_param("fan", "auto") + await self.tec_client.set_param("fan", "auto") else: - await tec_client.set_param("fan", self.fan_power_slider.value()) + await self.tec_client.set_param("fan", self.fan_power_slider.value()) @asyncSlot() async def connect(self): - global tec_client - ip, port = self.ip_set_line.text(), self.port_set_spin.value() try: - if tec_client is None: + if self.tec_client is None: self.status_lbl.setText("Connecting...") - tec_client = Client() - await tec_client.connect(host=ip, port=port, timeout=30) + self.tec_client = Client() + await self.tec_client.connect(host=ip, port=port, timeout=30) self._on_connection_changed(True) - self._hw_rev(await tec_client.hw_rev()) - # self.fan_update(await tec_client.fan()) + self._hw_rev(await self.tec_client.hw_rev()) + # self.fan_update(await self.tec_client.fan()) - self.client_watcher = ClientWatcher(self.main_widget, self.report_refresh_spin.value()) + self.client_watcher = ClientWatcher(self.main_widget, self.tec_client, self.report_refresh_spin.value()) self.client_watcher.fan_update.connect(self.fan_update) self.report_apply_btn.clicked.connect( lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) @@ -155,8 +151,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): QtWidgets.QApplication.instance().aboutToQuit.connect(self.client_watcher.stop_watching) self.client_watcher_task = asyncio.create_task(self.client_watcher.run()) else: - await tec_client.disconnect() - tec_client = None + await self.tec_client.disconnect() + self.tec_client = None self._on_connection_changed(False) self.client_watcher.stop_watching() From 142fe1043c108b393db03b65f7dddd7f64034cb2 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 5 Jul 2023 13:00:56 +0800 Subject: [PATCH 031/247] Remove unused 'as' clause --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e582053..26279fc 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -109,10 +109,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): logging.debug(fan_settings) if fan_settings is None: return - with QSignalBlocker(self.fan_power_slider) as _: + with QSignalBlocker(self.fan_power_slider): self.fan_power_slider.setValue(fan_settings["fan_pwm"]) self.fan_power_slider.setEnabled(not fan_settings["auto_mode"]) - with QSignalBlocker(self.fan_auto_box) as _: + with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(fan_settings["auto_mode"]) @asyncSlot() From 6b4b576518e02555bed6dda350176cd88a221c8a Mon Sep 17 00:00:00 2001 From: atse Date: Sat, 1 Jul 2023 23:41:48 +0800 Subject: [PATCH 032/247] Fix hardware revision showing major.major --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 26279fc..2992806 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -96,7 +96,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _hw_rev(self, hw_rev_d: dict): logging.debug(hw_rev_d) - self.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['major']}") + self.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}") self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"]) if hw_rev_d["settings"]["fan_pwm_recommended"]: self.fan_group.setStyleSheet("") From ec9ce6537ce510ac8744cb4cfef58b4c2709b8d7 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 5 Jul 2023 15:37:21 +0800 Subject: [PATCH 033/247] More helpful tooltip --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2992806..a520cba 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -103,7 +103,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_group.setToolTip("") else: self.fan_group.setStyleSheet("background-color: yellow") - self.fan_group.setToolTip("Changing the fan settings of not recommended") + self.fan_group.setToolTip("fan_pwm not recommended on this hardware revision") def fan_update(self, fan_settings): logging.debug(fan_settings) From 608573c03cd4bbffba00d681199a04f94f0428d3 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 5 Jul 2023 16:14:31 +0800 Subject: [PATCH 034/247] Update fan too --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a520cba..1d87637 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -141,7 +141,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): await self.tec_client.connect(host=ip, port=port, timeout=30) self._on_connection_changed(True) self._hw_rev(await self.tec_client.hw_rev()) - # self.fan_update(await self.tec_client.fan()) + self.fan_update(await self.tec_client.fan()) self.client_watcher = ClientWatcher(self.main_widget, self.tec_client, self.report_refresh_spin.value()) self.client_watcher.fan_update.connect(self.fan_update) From d0d33f42da232b126a3d9350a464e29ac9d85d73 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 6 Jul 2023 11:21:56 +0800 Subject: [PATCH 035/247] Rearrange client_watcher to hold its own task --- pytec/tec_qt.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 1d87637..23b81a9 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -38,6 +38,7 @@ class ClientWatcher(QObject): self.update_s = update_s self.running = True self.client = client + self.watch_task = None super().__init__(parent) async def run(self): @@ -50,6 +51,9 @@ class ClientWatcher(QObject): async def update_params(self): self.fan_update.emit(await self.client.fan()) + def start_watching(self): + self.watch_task = asyncio.create_task(self.run()) + @pyqtSlot() def stop_watching(self): self.running = False @@ -71,7 +75,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.tec_client: Client = None self.client_watcher: ClientWatcher = None - self.client_watcher_task = None if args.connect: if args.IP: @@ -149,16 +152,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) QtWidgets.QApplication.instance().aboutToQuit.connect(self.client_watcher.stop_watching) - self.client_watcher_task = asyncio.create_task(self.client_watcher.run()) + self.client_watcher.start_watching() else: + self.client_watcher.stop_watching() + self.client_watcher = None + await self.tec_client.disconnect() self.tec_client = None self._on_connection_changed(False) - self.client_watcher.stop_watching() - self.client_watcher = None - await self.client_watcher_task - self.client_watcher_task = None except Exception as e: logging.error(f"Failed communicating to the {ip}:{port}: {e}") self._on_connection_changed(False) From c476ad9f7df27cc83a04453c8c8dcbcfafcfbcf8 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 6 Jul 2023 12:39:08 +0800 Subject: [PATCH 036/247] Close client_watcher on closeEvent not aboutToQuit Mirrors --- pytec/tec_qt.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 23b81a9..e2bbc90 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -134,6 +134,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: await self.tec_client.set_param("fan", self.fan_power_slider.value()) + @asyncClose + async def closeEvent(self, event): + if self.client_watcher is not None: + self.client_watcher.stop_watching() + @asyncSlot() async def connect(self): ip, port = self.ip_set_line.text(), self.port_set_spin.value() @@ -146,12 +151,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._hw_rev(await self.tec_client.hw_rev()) self.fan_update(await self.tec_client.fan()) - self.client_watcher = ClientWatcher(self.main_widget, self.tec_client, self.report_refresh_spin.value()) + self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) self.client_watcher.fan_update.connect(self.fan_update) self.report_apply_btn.clicked.connect( lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) - QtWidgets.QApplication.instance().aboutToQuit.connect(self.client_watcher.stop_watching) self.client_watcher.start_watching() else: self.client_watcher.stop_watching() From c261ca2447a84f2238755c93acc1134693166e83 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 6 Jul 2023 12:42:22 +0800 Subject: [PATCH 037/247] Disconnect client too on close --- pytec/tec_qt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e2bbc90..7c34377 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -138,6 +138,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): async def closeEvent(self, event): if self.client_watcher is not None: self.client_watcher.stop_watching() + if self.tec_client is not None: + await self.tec_client.disconnect() @asyncSlot() async def connect(self): From a55589415d17defaf661e0af6969a09bf5f98fe6 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 6 Jul 2023 13:00:53 +0800 Subject: [PATCH 038/247] Cancel task to stop watch --- pytec/tec_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7c34377..c2e54d0 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -57,6 +57,7 @@ class ClientWatcher(QObject): @pyqtSlot() def stop_watching(self): self.running = False + self.watch_task.cancel() @pyqtSlot() def set_update_s(self, update_s): From ad5e36beab72331c50153124bdffb7452e960cb6 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 6 Jul 2023 17:04:20 +0800 Subject: [PATCH 039/247] Add unit to report spinbox --- pytec/tec_qt.ui | 3 +++ pytec/ui_tec_qt.py | 1 + 2 files changed, 4 insertions(+) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 072befa..b108b7c 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -377,6 +377,9 @@ 0 + + s + 1 diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 9e502d5..0a727bb 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -304,6 +304,7 @@ class Ui_MainWindow(object): self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) + self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) self.report_apply_btn.setText(_translate("MainWindow", "Apply")) self.fan_lbl.setText(_translate("MainWindow", "Fan:")) From 1fd49360d06568b8482c5a0e59a69edba2bcb27a Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 7 Jul 2023 16:31:47 +0800 Subject: [PATCH 040/247] Lock connection details while connecting Fix connect button behaviour --- pytec/tec_qt.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c2e54d0..07aa5ac 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -148,8 +148,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): try: if self.tec_client is None: self.status_lbl.setText("Connecting...") + self.ip_set_line.setEnabled(False) + self.port_set_spin.setEnabled(False) + self.connect_btn.setEnabled(False) + self.tec_client = Client() await self.tec_client.connect(host=ip, port=port, timeout=30) + + self.connect_btn.setEnabled(True) self._on_connection_changed(True) self._hw_rev(await self.tec_client.hw_rev()) self.fan_update(await self.tec_client.fan()) @@ -171,6 +177,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): except Exception as e: logging.error(f"Failed communicating to the {ip}:{port}: {e}") self._on_connection_changed(False) + self.connect_btn.setEnabled(True) async def coro_main(): From 84018c3ebcde73a378cb0d34916e580c21aa51bb Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 7 Jul 2023 16:09:03 +0800 Subject: [PATCH 041/247] Start running only when task is running --- pytec/tec_qt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 07aa5ac..b417319 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -36,7 +36,7 @@ class ClientWatcher(QObject): def __init__(self, parent, client, update_s): self.update_s = update_s - self.running = True + self.running = False self.client = client self.watch_task = None super().__init__(parent) @@ -52,6 +52,7 @@ class ClientWatcher(QObject): self.fan_update.emit(await self.client.fan()) def start_watching(self): + self.running = True self.watch_task = asyncio.create_task(self.run()) @pyqtSlot() From 1849711c624cf273128a54a78cedbc19432a946e Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 7 Jul 2023 16:32:16 +0800 Subject: [PATCH 042/247] Set client to none if failed to connect Fixes connect button behaviour after accidental disconnect --- pytec/tec_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b417319..fe89724 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -179,6 +179,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): logging.error(f"Failed communicating to the {ip}:{port}: {e}") self._on_connection_changed(False) self.connect_btn.setEnabled(True) + self.tec_client = None async def coro_main(): From 5e105884d173361217abb0f798a79592621293b5 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 11 Jul 2023 11:55:30 +0800 Subject: [PATCH 043/247] Use slider signal argument to set fan value --- pytec/tec_qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index fe89724..2069c37 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -120,11 +120,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(fan_settings["auto_mode"]) - @asyncSlot() - async def fan_set(self): + @asyncSlot(int) + async def fan_set(self, value): if self.tec_client is None or self.fan_auto_box.isChecked(): return - await self.tec_client.set_param("fan", self.fan_power_slider.value()) + await self.tec_client.set_param("fan", value) @asyncSlot(int) async def fan_auto_set(self, enabled): From 3d801666faa00ec5d705379873163db4e28a209d Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 11 Jul 2023 12:28:20 +0800 Subject: [PATCH 044/247] Update fan slider value immediately after fan auto --- pytec/tec_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2069c37..53a0e0f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -133,6 +133,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.setEnabled(not enabled) if enabled: await self.tec_client.set_param("fan", "auto") + self.fan_update(await self.tec_client.fan()) else: await self.tec_client.set_param("fan", self.fan_power_slider.value()) From 8045d8c93d9376a640970c29d2fbb055fef94376 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 12:43:44 +0800 Subject: [PATCH 045/247] Grammar --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 53a0e0f..996ffc2 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -177,7 +177,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._on_connection_changed(False) except Exception as e: - logging.error(f"Failed communicating to the {ip}:{port}: {e}") + logging.error(f"Failed communicating to {ip}:{port}: {e}") self._on_connection_changed(False) self.connect_btn.setEnabled(True) self.tec_client = None From 47dbe95045b581e6c9c34a4981c3dbb0cf7221e1 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 7 Jul 2023 15:36:17 +0800 Subject: [PATCH 046/247] Replace fan group highlighting with warning icon Highlighting is too confusing --- pytec/tec_qt.py | 15 +++++++++------ pytec/tec_qt.ui | 13 +++++++++++++ pytec/ui_tec_qt.py | 5 +++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 996ffc2..b98dc5a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,4 +1,4 @@ -from PyQt6 import QtWidgets +from PyQt6 import QtWidgets, QtGui from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot from pyqtgraph import PlotWidget from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType @@ -97,18 +97,21 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connect_btn.setText("Disconnect" if result else "Connect") if not result: self.hw_rev_lbl.setText("Thermostat vX.Y") - self.fan_group.setStyleSheet("") + self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) + self.fan_pwm_warning.setToolTip("") def _hw_rev(self, hw_rev_d: dict): logging.debug(hw_rev_d) self.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}") self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"]) if hw_rev_d["settings"]["fan_pwm_recommended"]: - self.fan_group.setStyleSheet("") - self.fan_group.setToolTip("") + self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) + self.fan_pwm_warning.setToolTip("") else: - self.fan_group.setStyleSheet("background-color: yellow") - self.fan_group.setToolTip("fan_pwm not recommended on this hardware revision") + 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("Fan power adjustment not recommended on this hardware revision!") def fan_update(self, fan_settings): logging.debug(fan_settings) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index b108b7c..0557111 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -503,6 +503,19 @@ 9 + + + + + 16 + 0 + + + + + + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 0a727bb..bc06921 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -227,6 +227,11 @@ class Ui_MainWindow(object): self.gan_layout = QtWidgets.QHBoxLayout() self.gan_layout.setSpacing(9) self.gan_layout.setObjectName("gan_layout") + 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.fan_pwm_warning.setObjectName("fan_pwm_warning") + self.gan_layout.addWidget(self.fan_pwm_warning) self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) From 54bb740a4151b79a8aaf2d0950e5498bffe42779 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 7 Jul 2023 17:19:17 +0800 Subject: [PATCH 047/247] Only warn about fan pwm when not at full strength --- pytec/tec_qt.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b98dc5a..172d5c0 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -75,6 +75,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) + self.fan_pwm_recommended = False + self.tec_client: Client = None self.client_watcher: ClientWatcher = None @@ -100,18 +102,21 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") - def _hw_rev(self, hw_rev_d: dict): - logging.debug(hw_rev_d) - self.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}") - self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"]) - if hw_rev_d["settings"]["fan_pwm_recommended"]: - self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) - self.fan_pwm_warning.setToolTip("") - else: + 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("Fan power adjustment not recommended on this hardware revision!") + else: + self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) + self.fan_pwm_warning.setToolTip("") + + def _hw_rev(self, hw_rev_d: dict): + logging.debug(hw_rev_d) + self.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}") + self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"]) + self.fan_pwm_recommended = hw_rev_d["settings"]["fan_pwm_recommended"] def fan_update(self, fan_settings): logging.debug(fan_settings) @@ -122,12 +127,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.setEnabled(not fan_settings["auto_mode"]) with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(fan_settings["auto_mode"]) + if not self.fan_pwm_recommended: + self._set_fan_pwm_warning() @asyncSlot(int) async def fan_set(self, value): if self.tec_client is None or self.fan_auto_box.isChecked(): return await self.tec_client.set_param("fan", value) + if not self.fan_pwm_recommended: + self._set_fan_pwm_warning() @asyncSlot(int) async def fan_auto_set(self, enabled): From ac77c457ec6aac81749f11664ce47eb4f55562f5 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 7 Jul 2023 17:44:03 +0800 Subject: [PATCH 048/247] Fix fan warning wording --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 172d5c0..be47a6c 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -107,7 +107,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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("Fan power adjustment not recommended on this hardware revision!") + 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("") From 9f839f4bd932bbdd6a7418fcae830b96d1a93bbe Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 11 Jul 2023 11:48:38 +0800 Subject: [PATCH 049/247] Handle UI when fan_pwm is 0 --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index be47a6c..7ac6560 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -123,7 +123,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if fan_settings is None: return with QSignalBlocker(self.fan_power_slider): - self.fan_power_slider.setValue(fan_settings["fan_pwm"]) + self.fan_power_slider.setValue(fan_settings["fan_pwm"] or 100) # 0 = PWM off = full strength self.fan_power_slider.setEnabled(not fan_settings["auto_mode"]) with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(fan_settings["auto_mode"]) From fca4b061ee7cf0111aba4c53ff39aa0378880dfc Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 11 Jul 2023 12:27:43 +0800 Subject: [PATCH 050/247] Fix Slot decorators and types --- pytec/tec_qt.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7ac6560..e1098ae 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -29,10 +29,10 @@ def get_argparser(): class ClientWatcher(QObject): - fan_update = pyqtSignal(object) - pwm_update = pyqtSignal(object) - report_update = pyqtSignal(object) - pid_update = pyqtSignal(object) + fan_update = pyqtSignal(dict) + pwm_update = pyqtSignal(list) + report_update = pyqtSignal(list) + pid_update = pyqtSignal(list) def __init__(self, parent, client, update_s): self.update_s = update_s @@ -60,7 +60,7 @@ class ClientWatcher(QObject): self.running = False self.watch_task.cancel() - @pyqtSlot() + @pyqtSlot(float) def set_update_s(self, update_s): self.update_s = update_s @@ -118,7 +118,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"]) self.fan_pwm_recommended = hw_rev_d["settings"]["fan_pwm_recommended"] - def fan_update(self, fan_settings): + @pyqtSlot(dict) + def fan_update(self, fan_settings: dict): logging.debug(fan_settings) if fan_settings is None: return From 7e1b64b72c13b3fce0776e1c9a86ac17e5dd913d Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 11 Jul 2023 12:28:44 +0800 Subject: [PATCH 051/247] Set fan slider value minimum to 1, not 0 --- pytec/tec_qt.ui | 3 +++ pytec/ui_tec_qt.py | 1 + 2 files changed, 4 insertions(+) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 0557111..bc97494 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -573,6 +573,9 @@ 0 + + 1 + 100 diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index bc06921..63fe098 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -252,6 +252,7 @@ class Ui_MainWindow(object): 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.fan_power_slider.setObjectName("fan_power_slider") From 115c7eb800424a5ade4a49793b3b44add419e24a Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 11 Jul 2023 13:43:09 +0800 Subject: [PATCH 052/247] Add stop connection button Stuff to add to stop button --- pytec/tec_qt.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e1098ae..7956b88 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -165,10 +165,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.status_lbl.setText("Connecting...") self.ip_set_line.setEnabled(False) self.port_set_spin.setEnabled(False) - self.connect_btn.setEnabled(False) + self.connect_btn.setText("Stop") self.tec_client = Client() - await self.tec_client.connect(host=ip, port=port, timeout=30) + self.connect_task = asyncio.create_task(self.tec_client.connect(host=ip, port=port, timeout=30)) + try: + await self.connect_task + except asyncio.exceptions.CancelledError: + return self.connect_btn.setEnabled(True) self._on_connection_changed(True) @@ -182,8 +186,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) self.client_watcher.start_watching() else: - self.client_watcher.stop_watching() - self.client_watcher = None + if self.client_watcher is None: + self.connect_task.cancel() + self.tec_client = None + self.on_connection_changed(False) + return + else: + self.client_watcher.stop_watching() + self.client_watcher = None await self.tec_client.disconnect() self.tec_client = None @@ -191,9 +201,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): except Exception as e: logging.error(f"Failed communicating to {ip}:{port}: {e}") + await self.tec_client.disconnect() self._on_connection_changed(False) - self.connect_btn.setEnabled(True) - self.tec_client = None async def coro_main(): From fd83ee23e1d7f72e493038455b99ea70320a5f5c Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 11 Jul 2023 17:45:57 +0800 Subject: [PATCH 053/247] Catch a more specific exception --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7956b88..4fd3ff5 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -199,7 +199,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.tec_client = None self._on_connection_changed(False) - except Exception as e: + except (OSError, TimeoutError) as e: logging.error(f"Failed communicating to {ip}:{port}: {e}") await self.tec_client.disconnect() self._on_connection_changed(False) From 917a2546cc936be825c2f27961a5e85ba907919e Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 13 Jul 2023 15:45:08 +0800 Subject: [PATCH 054/247] Remove is running loop variable Just use Task.done() --- pytec/tec_qt.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 4fd3ff5..8a9f884 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -36,14 +36,13 @@ class ClientWatcher(QObject): def __init__(self, parent, client, update_s): self.update_s = update_s - self.running = False self.client = client self.watch_task = None super().__init__(parent) async def run(self): loop = asyncio.get_running_loop() - while self.running: + while True: time = loop.time() await self.update_params() await asyncio.sleep(self.update_s - (loop.time() - time)) @@ -52,12 +51,10 @@ class ClientWatcher(QObject): self.fan_update.emit(await self.client.fan()) def start_watching(self): - self.running = True self.watch_task = asyncio.create_task(self.run()) @pyqtSlot() def stop_watching(self): - self.running = False self.watch_task.cancel() @pyqtSlot(float) From 7e56f2d8797d35c5c8d8f9f4878c95fefb914b27 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 13 Jul 2023 17:03:49 +0800 Subject: [PATCH 055/247] Rearrange bottom bar for new context menu --- pytec/tec_qt.py | 10 +- pytec/tec_qt.ui | 359 ++++++++++++++++++++------------------------- pytec/ui_tec_qt.py | 153 ++++++++----------- 3 files changed, 225 insertions(+), 297 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8a9f884..781a6a8 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -86,16 +86,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _on_connection_changed(self, result): self.graph_group.setEnabled(result) - self.hw_rev_lbl.setEnabled(result) self.fan_group.setEnabled(result) self.report_group.setEnabled(result) self.ip_set_line.setEnabled(not result) self.port_set_spin.setEnabled(not result) - self.status_lbl.setText("Connected" if result else "Disconnected") self.connect_btn.setText("Disconnect" if result else "Connect") if not result: - self.hw_rev_lbl.setText("Thermostat vX.Y") + self.status_lbl.setText("Disconnected") self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") @@ -109,9 +107,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") - def _hw_rev(self, hw_rev_d: dict): + def _status(self, hw_rev_d: dict): logging.debug(hw_rev_d) - self.hw_rev_lbl.setText(f"Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}") + 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"]) self.fan_pwm_recommended = hw_rev_d["settings"]["fan_pwm_recommended"] @@ -173,7 +171,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connect_btn.setEnabled(True) self._on_connection_changed(True) - self._hw_rev(await self.tec_client.hw_rev()) + self._status(await self.tec_client.hw_rev()) self.fan_update(await self.tec_client.fan()) self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index bc97494..551ef66 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -307,6 +307,162 @@ + + + + false + + + + 40 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 9 + + + + + + 16 + 0 + + + + + + + + + + + + 0 + 0 + + + + + 40 + 0 + + + + + 40 + 16777215 + + + + + 40 + 0 + + + + Fan: + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + + 200 + 16777215 + + + + + 200 + 0 + + + + 1 + + + 100 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + Auto + + + + + + + + + + + + + 0 + 0 + + + + Qt::Vertical + + + @@ -458,209 +614,6 @@ - - - - - 0 - 0 - - - - Qt::Vertical - - - - - - - false - - - - 40 - 0 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 9 - - - - - - 16 - 0 - - - - - - - - - - - - 0 - 0 - - - - - 40 - 0 - - - - - 40 - 16777215 - - - - - 40 - 0 - - - - Fan: - - - - - - - - 0 - 0 - - - - - 200 - 0 - - - - - 200 - 16777215 - - - - - 200 - 0 - - - - 1 - - - 100 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 70 - 0 - - - - - 70 - 16777215 - - - - Auto - - - - - - - - - - - - - 0 - 0 - - - - Qt::Vertical - - - - - - - false - - - - 0 - 0 - - - - - 150 - 0 - - - - - 150 - 16777215 - - - - - 150 - 0 - - - - Thermostat vX.Y - - - diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 63fe098..8144ebd 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -146,6 +146,69 @@ class Ui_MainWindow(object): self.line_0.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) self.line_0.setObjectName("line_0") self.settings_layout.addWidget(self.line_0) + self.fan_group = QtWidgets.QWidget(parent=self.bottom_settings_group) + self.fan_group.setEnabled(False) + self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_group.setObjectName("fan_group") + self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group) + self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout_6.setSpacing(0) + self.horizontalLayout_6.setObjectName("horizontalLayout_6") + self.gan_layout = QtWidgets.QHBoxLayout() + self.gan_layout.setSpacing(9) + self.gan_layout.setObjectName("gan_layout") + 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.fan_pwm_warning.setObjectName("fan_pwm_warning") + self.gan_layout.addWidget(self.fan_pwm_warning) + 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.fan_lbl.setObjectName("fan_lbl") + 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.fan_power_slider.setObjectName("fan_power_slider") + 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.fan_auto_box.setObjectName("fan_auto_box") + self.gan_layout.addWidget(self.fan_auto_box) + self.horizontalLayout_6.addLayout(self.gan_layout) + self.settings_layout.addWidget(self.fan_group) + self.line_1 = QtWidgets.QFrame(parent=self.bottom_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.line_1.sizePolicy().hasHeightForWidth()) + self.line_1.setSizePolicy(sizePolicy) + self.line_1.setFrameShape(QtWidgets.QFrame.Shape.VLine) + self.line_1.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + self.line_1.setObjectName("line_1") + self.settings_layout.addWidget(self.line_1) self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group) self.report_group.setEnabled(False) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) @@ -206,91 +269,6 @@ class Ui_MainWindow(object): self.report_layout.setStretch(2, 1) self.horizontalLayout_4.addLayout(self.report_layout) self.settings_layout.addWidget(self.report_group) - self.line_1 = QtWidgets.QFrame(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.line_1.sizePolicy().hasHeightForWidth()) - self.line_1.setSizePolicy(sizePolicy) - self.line_1.setFrameShape(QtWidgets.QFrame.Shape.VLine) - self.line_1.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.line_1.setObjectName("line_1") - self.settings_layout.addWidget(self.line_1) - self.fan_group = QtWidgets.QWidget(parent=self.bottom_settings_group) - self.fan_group.setEnabled(False) - self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) - self.fan_group.setObjectName("fan_group") - self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group) - self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout_6.setSpacing(0) - self.horizontalLayout_6.setObjectName("horizontalLayout_6") - self.gan_layout = QtWidgets.QHBoxLayout() - self.gan_layout.setSpacing(9) - self.gan_layout.setObjectName("gan_layout") - 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.fan_pwm_warning.setObjectName("fan_pwm_warning") - self.gan_layout.addWidget(self.fan_pwm_warning) - 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.fan_lbl.setObjectName("fan_lbl") - 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.fan_power_slider.setObjectName("fan_power_slider") - 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.fan_auto_box.setObjectName("fan_auto_box") - self.gan_layout.addWidget(self.fan_auto_box) - self.horizontalLayout_6.addLayout(self.gan_layout) - self.settings_layout.addWidget(self.fan_group) - self.line_3 = QtWidgets.QFrame(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.line_3.sizePolicy().hasHeightForWidth()) - self.line_3.setSizePolicy(sizePolicy) - self.line_3.setFrameShape(QtWidgets.QFrame.Shape.VLine) - self.line_3.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.line_3.setObjectName("line_3") - self.settings_layout.addWidget(self.line_3) - self.hw_rev_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group) - self.hw_rev_lbl.setEnabled(False) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.hw_rev_lbl.sizePolicy().hasHeightForWidth()) - self.hw_rev_lbl.setSizePolicy(sizePolicy) - self.hw_rev_lbl.setMinimumSize(QtCore.QSize(150, 0)) - self.hw_rev_lbl.setMaximumSize(QtCore.QSize(150, 16777215)) - self.hw_rev_lbl.setBaseSize(QtCore.QSize(150, 0)) - self.hw_rev_lbl.setObjectName("hw_rev_lbl") - self.settings_layout.addWidget(self.hw_rev_lbl) self.horizontalLayout_2.addLayout(self.settings_layout) self.main_layout.addWidget(self.bottom_settings_group) self.gridLayout_2.addLayout(self.main_layout, 0, 1, 1, 1) @@ -310,12 +288,11 @@ class Ui_MainWindow(object): self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) + self.fan_lbl.setText(_translate("MainWindow", "Fan:")) + self.fan_auto_box.setText(_translate("MainWindow", "Auto")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) self.report_apply_btn.setText(_translate("MainWindow", "Apply")) - self.fan_lbl.setText(_translate("MainWindow", "Fan:")) - self.fan_auto_box.setText(_translate("MainWindow", "Auto")) - self.hw_rev_lbl.setText(_translate("MainWindow", "Thermostat vX.Y")) from pyqtgraph import PlotWidget from pyqtgraph.parametertree import ParameterTree From fa60439e3922590f64609726803cb2ca21b976bc Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 14 Jul 2023 15:49:17 +0800 Subject: [PATCH 056/247] Put the connecting task in aioclient --- pytec/pytec/aioclient.py | 10 +++++++++- pytec/tec_qt.py | 13 +++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index b67d15f..f6964a9 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -9,13 +9,21 @@ class Client: def __init__(self): self._reader = None self._writer = None + self._connecting_task = None self._command_lock = asyncio.Lock() async def connect(self, host='192.168.1.26', port=23, timeout=None): - self._reader, self._writer = await asyncio.open_connection(host, port) + self._connecting_task = asyncio.create_task(asyncio.open_connection(host, port)) + self._reader, self._writer = await self._connecting_task + self._connecting_task = None + await self._check_zero_limits() async def disconnect(self): + if self._connecting_task is not None: + self._connecting_task.cancel() + return + self._writer.close() await self._writer.wait_closed() diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 781a6a8..6b66ffc 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -163,13 +163,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connect_btn.setText("Stop") self.tec_client = Client() - self.connect_task = asyncio.create_task(self.tec_client.connect(host=ip, port=port, timeout=30)) try: - await self.connect_task - except asyncio.exceptions.CancelledError: + await self.tec_client.connect(host=ip, port=port, timeout=30) + except asyncio.CancelledError: return - self.connect_btn.setEnabled(True) self._on_connection_changed(True) self._status(await self.tec_client.hw_rev()) self.fan_update(await self.tec_client.fan()) @@ -181,12 +179,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) self.client_watcher.start_watching() else: - if self.client_watcher is None: - self.connect_task.cancel() - self.tec_client = None - self.on_connection_changed(False) - return - else: + if self.client_watcher is not None: self.client_watcher.stop_watching() self.client_watcher = None From 659d0d08354d6c0bba0221b8dbbc03fa6292fb92 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 14 Jul 2023 16:01:53 +0800 Subject: [PATCH 057/247] Init client once No none-ing --- pytec/pytec/aioclient.py | 9 +++++++++ pytec/tec_qt.py | 12 +++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index f6964a9..b474c8e 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -19,13 +19,22 @@ class Client: await self._check_zero_limits() + def is_connecting(self): + return self._connecting_task is not None + + def is_connected(self): + return self._reader is not None + async def disconnect(self): if self._connecting_task is not None: self._connecting_task.cancel() + self._connecting_task = None return self._writer.close() await self._writer.wait_closed() + self._reader = None + self._writer = None async def _check_zero_limits(self): pwm_report = await self.get_pwm() diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 6b66ffc..c8ca2ea 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -74,7 +74,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_pwm_recommended = False - self.tec_client: Client = None + self.tec_client = Client() self.client_watcher: ClientWatcher = None if args.connect: @@ -128,7 +128,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(int) async def fan_set(self, value): - if self.tec_client is None or self.fan_auto_box.isChecked(): + if not self.tec_client.is_connected() or self.fan_auto_box.isChecked(): return await self.tec_client.set_param("fan", value) if not self.fan_pwm_recommended: @@ -136,7 +136,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(int) async def fan_auto_set(self, enabled): - if self.tec_client is None: + if not self.tec_client.is_connected(): return self.fan_power_slider.setEnabled(not enabled) if enabled: @@ -149,20 +149,19 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): async def closeEvent(self, event): if self.client_watcher is not None: self.client_watcher.stop_watching() - if self.tec_client is not None: + if self.tec_client.is_connecting() or self.tec_client.is_connected(): await self.tec_client.disconnect() @asyncSlot() async def connect(self): ip, port = self.ip_set_line.text(), self.port_set_spin.value() try: - if self.tec_client is None: + if not (self.tec_client.is_connecting() or self.tec_client.is_connected()): self.status_lbl.setText("Connecting...") self.ip_set_line.setEnabled(False) self.port_set_spin.setEnabled(False) self.connect_btn.setText("Stop") - self.tec_client = Client() try: await self.tec_client.connect(host=ip, port=port, timeout=30) except asyncio.CancelledError: @@ -184,7 +183,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.client_watcher = None await self.tec_client.disconnect() - self.tec_client = None self._on_connection_changed(False) except (OSError, TimeoutError) as e: From 27ce3111116c54bffb8fe261acb1cb792bbb7f99 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 14 Jul 2023 16:10:59 +0800 Subject: [PATCH 058/247] Init client_watcher once --- pytec/tec_qt.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c8ca2ea..69a5f01 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -53,9 +53,14 @@ class ClientWatcher(QObject): def start_watching(self): self.watch_task = asyncio.create_task(self.run()) + def is_watching(self): + return self.watch_task is not None + @pyqtSlot() def stop_watching(self): - self.watch_task.cancel() + if self.watch_task is not None: + self.watch_task.cancel() + self.watch_task = None @pyqtSlot(float) def set_update_s(self, update_s): @@ -75,7 +80,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_pwm_recommended = False self.tec_client = Client() - self.client_watcher: ClientWatcher = None + self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) + self.client_watcher.fan_update.connect(self.fan_update) + self.report_apply_btn.clicked.connect( + lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) + ) if args.connect: if args.IP: @@ -147,7 +156,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncClose async def closeEvent(self, event): - if self.client_watcher is not None: + if self.client_watcher.is_watching(): self.client_watcher.stop_watching() if self.tec_client.is_connecting() or self.tec_client.is_connected(): await self.tec_client.disconnect() @@ -171,16 +180,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._status(await self.tec_client.hw_rev()) self.fan_update(await self.tec_client.fan()) - self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) - self.client_watcher.fan_update.connect(self.fan_update) - self.report_apply_btn.clicked.connect( - lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) - ) self.client_watcher.start_watching() else: - if self.client_watcher is not None: - self.client_watcher.stop_watching() - self.client_watcher = None + self.client_watcher.stop_watching() await self.tec_client.disconnect() self._on_connection_changed(False) From e727f8b06bd49d2a6c8f34c2d72e76e9896622e4 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 14 Jul 2023 16:16:52 +0800 Subject: [PATCH 059/247] Change statement order up a bit --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 69a5f01..c360a86 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -167,9 +167,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): try: if not (self.tec_client.is_connecting() or self.tec_client.is_connected()): self.status_lbl.setText("Connecting...") + self.connect_btn.setText("Stop") self.ip_set_line.setEnabled(False) self.port_set_spin.setEnabled(False) - self.connect_btn.setText("Stop") try: await self.tec_client.connect(host=ip, port=port, timeout=30) From 82438ee4a50af1f73be2f39b2dc7cd61a080e082 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 14 Jul 2023 16:52:13 +0800 Subject: [PATCH 060/247] Simplify stuff a bit --- pytec/pytec/aioclient.py | 6 +++++- pytec/tec_qt.py | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index b474c8e..e7c18e3 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -23,7 +23,7 @@ class Client: return self._connecting_task is not None def is_connected(self): - return self._reader is not None + return self._writer is not None async def disconnect(self): if self._connecting_task is not None: @@ -31,6 +31,10 @@ class Client: self._connecting_task = None return + if self._writer is None: + return + + # Reader needn't be closed self._writer.close() await self._writer.wait_closed() self._reader = None diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c360a86..9ba53a0 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -156,10 +156,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncClose async def closeEvent(self, event): - if self.client_watcher.is_watching(): - self.client_watcher.stop_watching() - if self.tec_client.is_connecting() or self.tec_client.is_connected(): - await self.tec_client.disconnect() + self.client_watcher.stop_watching() + await self.tec_client.disconnect() @asyncSlot() async def connect(self): From b4a5e90f2eaa4e3503f049935045c40393619059 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 10:28:56 +0800 Subject: [PATCH 061/247] Turn on_connection_changed to coroutine Further compresses the connect --- pytec/tec_qt.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 9ba53a0..71c744d 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -93,7 +93,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.port_set_spin.setValue(int(args.PORT)) self.connect_btn.click() - def _on_connection_changed(self, result): + async def _on_connection_changed(self, result): self.graph_group.setEnabled(result) self.fan_group.setEnabled(result) self.report_group.setEnabled(result) @@ -105,6 +105,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.status_lbl.setText("Disconnected") self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") + else: + self._status(await self.tec_client.hw_rev()) + self.fan_update(await self.tec_client.fan()) def _set_fan_pwm_warning(self): if self.fan_power_slider.value() != 100: @@ -174,21 +177,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): except asyncio.CancelledError: return - self._on_connection_changed(True) - self._status(await self.tec_client.hw_rev()) - self.fan_update(await self.tec_client.fan()) - + await self._on_connection_changed(True) self.client_watcher.start_watching() else: self.client_watcher.stop_watching() await self.tec_client.disconnect() - self._on_connection_changed(False) + await self._on_connection_changed(False) except (OSError, TimeoutError) as e: logging.error(f"Failed communicating to {ip}:{port}: {e}") await self.tec_client.disconnect() - self._on_connection_changed(False) + await self._on_connection_changed(False) async def coro_main(): From 5ced33594c388c7c305d21d47909ac4d7e7e9e2f Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 10:36:29 +0800 Subject: [PATCH 062/247] Change name of button slot --- pytec/tec_qt.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 71c744d..b1eea22 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -73,7 +73,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.setupUi(self) - self.connect_btn.clicked.connect(self.connect) self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) @@ -163,7 +162,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): await self.tec_client.disconnect() @asyncSlot() - async def connect(self): + async def on_connect_btn_clicked(self): ip, port = self.ip_set_line.text(), self.port_set_spin.value() try: if not (self.tec_client.is_connecting() or self.tec_client.is_connected()): From 981c28ac2763c35a5e1ad84af200305d899c4c7f Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 11:01:51 +0800 Subject: [PATCH 063/247] Conslidate connect & disconnect actions --- pytec/pytec/aioclient.py | 6 +++++- pytec/tec_qt.py | 11 ++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index e7c18e3..e87aa96 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -14,10 +14,14 @@ class Client: async def connect(self, host='192.168.1.26', port=23, timeout=None): self._connecting_task = asyncio.create_task(asyncio.open_connection(host, port)) - self._reader, self._writer = await self._connecting_task + try: + self._reader, self._writer = await self._connecting_task + except asyncio.CancelledError: + return False self._connecting_task = None await self._check_zero_limits() + return True def is_connecting(self): return self._connecting_task is not None diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b1eea22..0965ddf 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -104,7 +104,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.status_lbl.setText("Disconnected") self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") + self.client_watcher.stop_watching() else: + self.client_watcher.start_watching() self._status(await self.tec_client.hw_rev()) self.fan_update(await self.tec_client.fan()) @@ -171,16 +173,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.ip_set_line.setEnabled(False) self.port_set_spin.setEnabled(False) - try: - await self.tec_client.connect(host=ip, port=port, timeout=30) - except asyncio.CancelledError: + connected = await self.tec_client.connect(host=ip, port=port, timeout=30) + if not connected: return - await self._on_connection_changed(True) - self.client_watcher.start_watching() else: - self.client_watcher.stop_watching() - await self.tec_client.disconnect() await self._on_connection_changed(False) From 8520dae93b78c9656b215955e2a932a3f54a7c26 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 11:15:52 +0800 Subject: [PATCH 064/247] Update and add docstrings to aioclient --- pytec/pytec/aioclient.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index e87aa96..42da5d5 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -13,6 +13,15 @@ class Client: self._command_lock = asyncio.Lock() async def connect(self, host='192.168.1.26', port=23, timeout=None): + """Connect to the TEC with host and port, throws TimeoutError if + unable to connect. Returns True if not cancelled with disconnect. + + Example:: + client = aioclient.Client() + connected = await client.connect() + if connected: + return + """ self._connecting_task = asyncio.create_task(asyncio.open_connection(host, port)) try: self._reader, self._writer = await self._connecting_task @@ -24,12 +33,15 @@ class Client: return True def is_connecting(self): + """Returns True if client is connecting""" return self._connecting_task is not None def is_connected(self): + """Returns True if client is connected""" return self._writer is not None async def disconnect(self): + """Disconnect the client if connected, cancel connection if connecting""" if self._connecting_task is not None: self._connecting_task.cancel() self._connecting_task = None @@ -170,11 +182,11 @@ class Client: """Set configuration parameters Examples:: - tec.set_param("pwm", 0, "max_v", 2.0) - tec.set_param("pid", 1, "output_max", 2.5) - tec.set_param("s-h", 0, "t0", 20.0) - tec.set_param("center", 0, "vref") - tec.set_param("postfilter", 1, 21) + await tec.set_param("pwm", 0, "max_v", 2.0) + await tec.set_param("pid", 1, "output_max", 2.5) + await tec.set_param("s-h", 0, "t0", 20.0) + await tec.set_param("center", 0, "vref") + await tec.set_param("postfilter", 1, 21) See the firmware's README.md for a full list. """ From 1226cca6e63ae6ed0a9fefc2f186a2a1fbd0d086 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 11:21:32 +0800 Subject: [PATCH 065/247] Only set connecting task to None once --- pytec/pytec/aioclient.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 42da5d5..f65b150 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -27,7 +27,8 @@ class Client: self._reader, self._writer = await self._connecting_task except asyncio.CancelledError: return False - self._connecting_task = None + finally: + self._connecting_task = None await self._check_zero_limits() return True @@ -44,7 +45,6 @@ class Client: """Disconnect the client if connected, cancel connection if connecting""" if self._connecting_task is not None: self._connecting_task.cancel() - self._connecting_task = None return if self._writer is None: From 30f6c4f8291c8970f22598760fedb463c19b9630 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 11:26:40 +0800 Subject: [PATCH 066/247] Correct order once the tec_client disconnects to stop watching the client first --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 0965ddf..8c72bed 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -178,13 +178,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return await self._on_connection_changed(True) else: - await self.tec_client.disconnect() await self._on_connection_changed(False) + await self.tec_client.disconnect() except (OSError, TimeoutError) as e: logging.error(f"Failed communicating to {ip}:{port}: {e}") - await self.tec_client.disconnect() await self._on_connection_changed(False) + await self.tec_client.disconnect() async def coro_main(): From 1d192f50c8bb65893a9a01d823c65046092408fb Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 11:44:44 +0800 Subject: [PATCH 067/247] Remove redundant return --- pytec/pytec/aioclient.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index f65b150..77b84a7 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -45,7 +45,6 @@ class Client: """Disconnect the client if connected, cancel connection if connecting""" if self._connecting_task is not None: self._connecting_task.cancel() - return if self._writer is None: return From 463ee4105c638efcda924db2a4c2a97b61cd7082 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 19 Jul 2023 11:38:04 +0800 Subject: [PATCH 068/247] Context menu by QToolButton --- pytec/tec_qt.py | 3 +++ pytec/tec_qt.ui | 10 ++++++++++ pytec/ui_tec_qt.py | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8c72bed..6dff78f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -73,6 +73,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.setupUi(self) + menu = QtWidgets.QMenu(self) + self.thermostat_settings.setMenu(menu) + self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 551ef66..8430c9e 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -294,6 +294,16 @@ + + + + + + + QToolButton::InstantPopup + + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 8144ebd..2576458 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -136,6 +136,11 @@ class Ui_MainWindow(object): self.status_lbl.setBaseSize(QtCore.QSize(120, 50)) self.status_lbl.setObjectName("status_lbl") self.settings_layout.addWidget(self.status_lbl) + self.thermostat_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group) + self.thermostat_settings.setText("⚙") + self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) + self.thermostat_settings.setObjectName("thermostat_settings") + self.settings_layout.addWidget(self.thermostat_settings) self.line_0 = QtWidgets.QFrame(parent=self.bottom_settings_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) sizePolicy.setHorizontalStretch(0) From 774945970183d1886246ae02936184f686fa1351 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 19 Jul 2023 12:30:25 +0800 Subject: [PATCH 069/247] Max the label --- pytec/tec_qt.ui | 2 +- pytec/ui_tec_qt.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 8430c9e..d1661dc 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -273,7 +273,7 @@ - 120 + 240 0 diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 2576458..da0380f 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -131,7 +131,7 @@ class Ui_MainWindow(object): sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.status_lbl.sizePolicy().hasHeightForWidth()) self.status_lbl.setSizePolicy(sizePolicy) - self.status_lbl.setMinimumSize(QtCore.QSize(120, 0)) + self.status_lbl.setMinimumSize(QtCore.QSize(240, 0)) self.status_lbl.setMaximumSize(QtCore.QSize(120, 16777215)) self.status_lbl.setBaseSize(QtCore.QSize(120, 50)) self.status_lbl.setObjectName("status_lbl") From 71076510a2f11aeca3129dfe7ef4e533e6874a06 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 19 Jul 2023 12:49:15 +0800 Subject: [PATCH 070/247] Steal fan group and port??? Somehow --- pytec/tec_qt.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 6dff78f..ab694c1 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -73,8 +73,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.setupUi(self) - menu = QtWidgets.QMenu(self) - self.thermostat_settings.setMenu(menu) + self._set_up_context_menu() self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) @@ -95,6 +94,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.port_set_spin.setValue(int(args.PORT)) self.connect_btn.click() + def _set_up_context_menu(self): + self.menu = QtWidgets.QMenu() + self.menu.setTitle('Thermostat settings') + + fan = QtWidgets.QWidgetAction(self.menu) + fan.setDefaultWidget(self.fan_group) + self.menu.addAction(fan) + self.menu.fan = fan + + port = QtWidgets.QWidgetAction(self.menu) + port.setDefaultWidget(self.port_set_spin) + self.menu.addAction(port) + self.menu.port = port + + self.thermostat_settings.setMenu(self.menu) + async def _on_connection_changed(self, result): self.graph_group.setEnabled(result) self.fan_group.setEnabled(result) From e5b0583a91aaeab18762b30e43296acf9505b726 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 19 Jul 2023 14:35:51 +0800 Subject: [PATCH 071/247] Disable auto fan box if adjusted while auto --- pytec/tec_qt.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index ab694c1..a044b81 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -151,7 +151,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return with QSignalBlocker(self.fan_power_slider): self.fan_power_slider.setValue(fan_settings["fan_pwm"] or 100) # 0 = PWM off = full strength - self.fan_power_slider.setEnabled(not fan_settings["auto_mode"]) with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(fan_settings["auto_mode"]) if not self.fan_pwm_recommended: @@ -159,8 +158,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(int) async def fan_set(self, value): - if not self.tec_client.is_connected() or self.fan_auto_box.isChecked(): + if not self.tec_client.is_connected(): return + if self.fan_auto_box.isChecked(): + with QSignalBlocker(self.fan_auto_box): + self.fan_auto_box.setChecked(False) await self.tec_client.set_param("fan", value) if not self.fan_pwm_recommended: self._set_fan_pwm_warning() @@ -169,7 +171,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): async def fan_auto_set(self, enabled): if not self.tec_client.is_connected(): return - self.fan_power_slider.setEnabled(not enabled) if enabled: await self.tec_client.set_param("fan", "auto") self.fan_update(await self.tec_client.fan()) From 6c11a0536c04da411bff2f4171a6bc86a1c2dfe9 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 19 Jul 2023 15:04:12 +0800 Subject: [PATCH 072/247] Arrange context menu items to be in order --- pytec/tec_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a044b81..8f82881 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -98,16 +98,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu = QtWidgets.QMenu() self.menu.setTitle('Thermostat settings') - fan = QtWidgets.QWidgetAction(self.menu) - fan.setDefaultWidget(self.fan_group) - self.menu.addAction(fan) - self.menu.fan = fan - port = QtWidgets.QWidgetAction(self.menu) port.setDefaultWidget(self.port_set_spin) self.menu.addAction(port) self.menu.port = port + fan = QtWidgets.QWidgetAction(self.menu) + fan.setDefaultWidget(self.fan_group) + self.menu.addAction(fan) + self.menu.fan = fan + self.thermostat_settings.setMenu(self.menu) async def _on_connection_changed(self, result): From 04a8f5ea566da1392cf71e1c4edbc66f75ed7e3d Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 19 Jul 2023 16:35:00 +0800 Subject: [PATCH 073/247] Add tooltip to fan --- pytec/tec_qt.ui | 3 +++ pytec/ui_tec_qt.py | 1 + 2 files changed, 4 insertions(+) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index d1661dc..0eb9d6d 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -388,6 +388,9 @@ 0 + + Adjust the fan + Fan: diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index da0380f..983841d 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -293,6 +293,7 @@ class Ui_MainWindow(object): self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) + self.fan_lbl.setToolTip(_translate("MainWindow", "Adjust the fan")) self.fan_lbl.setText(_translate("MainWindow", "Fan:")) self.fan_auto_box.setText(_translate("MainWindow", "Auto")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) From b8d0cdabd3a113f4a1017bb2dee32a57b0240d77 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 20 Jul 2023 16:16:57 +0800 Subject: [PATCH 074/247] Swap order connected first --- pytec/tec_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8f82881..c786e3a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -118,15 +118,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.ip_set_line.setEnabled(not result) self.port_set_spin.setEnabled(not result) self.connect_btn.setText("Disconnect" if result else "Connect") - if not result: + if result: + self.client_watcher.start_watching() + self._status(await self.tec_client.hw_rev()) + self.fan_update(await self.tec_client.fan()) + else: self.status_lbl.setText("Disconnected") self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") self.client_watcher.stop_watching() - else: - self.client_watcher.start_watching() - self._status(await self.tec_client.hw_rev()) - self.fan_update(await self.tec_client.fan()) def _set_fan_pwm_warning(self): if self.fan_power_slider.value() != 100: From 8383abec8c1fbf9666e28aa9b98c290c0365b43a Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 20 Jul 2023 17:19:51 +0800 Subject: [PATCH 075/247] Add explanation of report rate --- pytec/tec_qt.ui | 12 +++++++++++- pytec/ui_tec_qt.py | 7 ++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 0eb9d6d..4916d43 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -510,7 +510,7 @@ 0 - + 6 @@ -520,6 +520,16 @@ 0 + + + + Poll every: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 983841d..138cbfe 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -232,6 +232,10 @@ class Ui_MainWindow(object): self.report_layout.setContentsMargins(0, -1, -1, -1) self.report_layout.setSpacing(6) self.report_layout.setObjectName("report_layout") + self.report_lbl = QtWidgets.QLabel(parent=self.report_group) + self.report_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.report_lbl.setObjectName("report_lbl") + self.report_layout.addWidget(self.report_lbl) self.report_refresh_spin = QtWidgets.QDoubleSpinBox(parent=self.report_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) @@ -269,9 +273,9 @@ class Ui_MainWindow(object): self.report_apply_btn.setBaseSize(QtCore.QSize(80, 0)) self.report_apply_btn.setObjectName("report_apply_btn") self.report_layout.addWidget(self.report_apply_btn) - self.report_layout.setStretch(0, 1) self.report_layout.setStretch(1, 1) self.report_layout.setStretch(2, 1) + self.report_layout.setStretch(3, 1) self.horizontalLayout_4.addLayout(self.report_layout) self.settings_layout.addWidget(self.report_group) self.horizontalLayout_2.addLayout(self.settings_layout) @@ -296,6 +300,7 @@ class Ui_MainWindow(object): self.fan_lbl.setToolTip(_translate("MainWindow", "Adjust the fan")) self.fan_lbl.setText(_translate("MainWindow", "Fan:")) self.fan_auto_box.setText(_translate("MainWindow", "Auto")) + self.report_lbl.setText(_translate("MainWindow", "Poll every: ")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) self.report_apply_btn.setText(_translate("MainWindow", "Apply")) From 928db9963d3bf5ddd8700fc3760cbbd711e139b5 Mon Sep 17 00:00:00 2001 From: Egor Savkin Date: Wed, 28 Jun 2023 15:01:47 +0800 Subject: [PATCH 076/247] Add paramtree view, without updates Signed-off-by: Egor Savkin Fix signal blocker argument -atse --- pytec/tec_qt.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c786e3a..022268b 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -15,6 +15,52 @@ from qasync import asyncSlot, asyncClose from ui_tec_qt import Ui_MainWindow +class CommandsParameter(Parameter): + def __init__(self, **opts): + super().__init__() + self.opts["commands"] = opts.get("commands", None) + self.opts["payload"] = opts.get("payload", None) + + +ThermostatParams = [[ + {'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'], 'payload': ch, + '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, + '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': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, + 'suffix': 'C', 'commands': [f's-h {ch} t0 {{value}}']}, + {'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm', + 'commands': [f's-h {ch} r0 {{value}}']}, + {'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1, 'commands': [f's-h {ch} b {{value}}']}, + ]}, + {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} kp {{value}}']}, + {'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} ki {{value}}']}, + {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1, '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, 'suffix': 'C'}, + {'name': 'Run', 'type': 'action', 'tip': 'Run'}, + ]}, + ]} +] for ch in range(2)] + +params = [CommandsParameter.create(name='Thermostat Params 0', type='group', children=ThermostatParams[0]), + CommandsParameter.create(name='Thermostat Params 1', type='group', children=ThermostatParams[1])] + + def get_argparser(): parser = argparse.ArgumentParser(description="ARTIQ master") @@ -49,6 +95,9 @@ class ClientWatcher(QObject): async def update_params(self): self.fan_update.emit(await self.client.fan()) + self.pwm_update.emit(await self.client.get_pwm()) + self.report_update.emit(await self.client._command("report")) + self.pid_update.emit(await self.client.get_pid()) def start_watching(self): self.watch_task = asyncio.create_task(self.run()) @@ -78,6 +127,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) + self._set_param_tree() + self.fan_pwm_recommended = False self.tec_client = Client() @@ -205,6 +256,39 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): await self._on_connection_changed(False) await self.tec_client.disconnect() + @asyncSlot(object, object) + async def send_command(self, param, changes): + for param, change, data in changes: + if param.name() == 'Temperature PID' and not data: + ch = param.opts["payload"] + await self.tec_client.set_param('pwm', ch, 'i_set', params[ch].child('Constant Current').value()) + elif param.opts.get("commands", None) is not None: + await asyncio.gather(*[self.tec_client._command(x.format(value=data)) for x in param.opts["commands"]]) + + def _set_param_tree(self): + self.ch0_tree.setParameters(params[0], showTop=False) + self.ch1_tree.setParameters(params[1], showTop=False) + params[0].sigTreeStateChanged.connect(self.send_command) + params[1].sigTreeStateChanged.connect(self.send_command) + + @pyqtSlot(list) + def update_pid(self, pid_settings): + for settings in pid_settings: + channel = settings["channel"] + with QSignalBlocker(params[channel]) as _: + params[channel].child("PID Config", "kP").setValue(settings["parameters"]["kp"]) + params[channel].child("PID Config", "kI").setValue(settings["parameters"]["ki"]) + params[channel].child("PID Config", "kD").setValue(settings["parameters"]["kd"]) + if params[channel].child("Temperature PID").value(): + params[channel].child("Temperature PID", "Set Temperature").setValue(settings["target"]) + + @pyqtSlot(list) + def update_report(self, report_data): + for settings in report_data: + channel = settings["channel"] + with QSignalBlocker(params[channel]) as _: + params[channel].child("Temperature PID").setValue(settings["pid_engaged"]) + async def coro_main(): args = get_argparser().parse_args() From 5ba189d3ba94dac142b34b9b9a1310f988f5b2c1 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 7 Jul 2023 12:41:09 +0800 Subject: [PATCH 077/247] Remove unused as clause --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 022268b..7989421 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -275,7 +275,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def update_pid(self, pid_settings): for settings in pid_settings: channel = settings["channel"] - with QSignalBlocker(params[channel]) as _: + with QSignalBlocker(params[channel]): params[channel].child("PID Config", "kP").setValue(settings["parameters"]["kp"]) params[channel].child("PID Config", "kI").setValue(settings["parameters"]["ki"]) params[channel].child("PID Config", "kD").setValue(settings["parameters"]["kd"]) @@ -286,7 +286,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def update_report(self, report_data): for settings in report_data: channel = settings["channel"] - with QSignalBlocker(params[channel]) as _: + with QSignalBlocker(params[channel]): params[channel].child("Temperature PID").setValue(settings["pid_engaged"]) From 137004e6b5b0267b1a53720e56e742811ef6f1f9 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 16 Aug 2023 17:35:13 +0800 Subject: [PATCH 078/247] Loop through trees to set them up --- pytec/tec_qt.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7989421..26fc706 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -266,10 +266,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): await asyncio.gather(*[self.tec_client._command(x.format(value=data)) for x in param.opts["commands"]]) def _set_param_tree(self): - self.ch0_tree.setParameters(params[0], showTop=False) - self.ch1_tree.setParameters(params[1], showTop=False) - params[0].sigTreeStateChanged.connect(self.send_command) - params[1].sigTreeStateChanged.connect(self.send_command) + for i, tree in enumerate((self.ch0_tree, self.ch1_tree)): + tree.setParameters(params[i], showTop=False) + params[i].sigTreeStateChanged.connect(self.send_command) @pyqtSlot(list) def update_pid(self, pid_settings): From 1b3f767d9481bfa32bef57bfbf0747ba20447bec Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 8 Aug 2023 12:41:57 +0800 Subject: [PATCH 079/247] Might as well be a pass, doens't exec --- pytec/tec_qt.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 26fc706..0441044 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -16,10 +16,7 @@ from ui_tec_qt import Ui_MainWindow class CommandsParameter(Parameter): - def __init__(self, **opts): - super().__init__() - self.opts["commands"] = opts.get("commands", None) - self.opts["payload"] = opts.get("payload", None) + pass ThermostatParams = [[ From abf5d5f2bd48143204dc66294bfd8e86c5e49ff6 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 14 Jul 2023 16:14:19 +0800 Subject: [PATCH 080/247] Fix formatting --- pytec/tec_qt.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 0441044..866e8ba 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -19,7 +19,7 @@ class CommandsParameter(Parameter): pass -ThermostatParams = [[ +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'], 'payload': ch, @@ -54,8 +54,11 @@ ThermostatParams = [[ ]} ] for ch in range(2)] -params = [CommandsParameter.create(name='Thermostat Params 0', type='group', children=ThermostatParams[0]), - CommandsParameter.create(name='Thermostat Params 1', type='group', children=ThermostatParams[1])] + +params = [ + CommandsParameter.create(name='Thermostat Params 0', type='group', children=THERMOSTAT_PARAMETERS[0]), + CommandsParameter.create(name='Thermostat Params 1', type='group', children=THERMOSTAT_PARAMETERS[1]), +] def get_argparser(): From 4961b2adb225f8eabc6dab4a43af649be6508907 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 18 Jul 2023 17:16:35 +0800 Subject: [PATCH 081/247] Use proper symbols in units, and add units PID parameters are not actually dimensionless, and their units can be deduced from the input unit and the output (actuator) unit. --- pytec/tec_qt.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 866e8ba..54e9ed8 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -35,20 +35,20 @@ THERMOSTAT_PARAMETERS = [[ 'suffix': 'V', 'commands': [f'pwm {ch} max_v {{value}}']}, ]}, {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, - 'suffix': 'C', 'commands': [f's-h {ch} t0 {{value}}']}, - {'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm', + {'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': 'Beta', 'type': 'float', 'value': 3950, 'step': 1, 'commands': [f's-h {ch} b {{value}}']}, + {'name': 'β', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'commands': [f's-h {ch} b {{value}}']}, ]}, {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} kp {{value}}']}, - {'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} ki {{value}}']}, - {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} kd {{value}}']}, + {'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': '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, 'suffix': 'C'}, + {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'suffix': '°C'}, {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, ]} @@ -275,9 +275,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for settings in pid_settings: channel = settings["channel"] with QSignalBlocker(params[channel]): - params[channel].child("PID Config", "kP").setValue(settings["parameters"]["kp"]) - params[channel].child("PID Config", "kI").setValue(settings["parameters"]["ki"]) - params[channel].child("PID Config", "kD").setValue(settings["parameters"]["kd"]) + params[channel].child("PID Config", "Kp").setValue(settings["parameters"]["kp"]) + params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"]) + params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"]) if params[channel].child("Temperature PID").value(): params[channel].child("Temperature PID", "Set Temperature").setValue(settings["target"]) From 0e3a01d60142dcf096f38d80f0d8777b1e0be03c Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 20 Jul 2023 13:47:39 +0800 Subject: [PATCH 082/247] Connect up report update --- pytec/tec_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 54e9ed8..18a7e70 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -134,6 +134,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.tec_client = Client() self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) self.client_watcher.fan_update.connect(self.fan_update) + self.client_watcher.report_update.connect(self.update_report) self.report_apply_btn.clicked.connect( lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) From 49c51206009c4f8afeddceda15bceeed36f048bd Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 20 Jul 2023 13:48:33 +0800 Subject: [PATCH 083/247] Connect up pid --- pytec/tec_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 18a7e70..81498fe 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -135,6 +135,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) self.client_watcher.fan_update.connect(self.fan_update) self.client_watcher.report_update.connect(self.update_report) + self.client_watcher.pid_update.connect(self.update_pid) self.report_apply_btn.clicked.connect( lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) From 8291b2052f4f43da2e928369de99af66a2f85d9a Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 20 Jul 2023 13:49:11 +0800 Subject: [PATCH 084/247] Add thermistor config & sync Note: The formula is not actually Steinhart-Hart --- pytec/tec_qt.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 81498fe..7108d3c 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -79,6 +79,7 @@ class ClientWatcher(QObject): pwm_update = pyqtSignal(list) report_update = pyqtSignal(list) pid_update = pyqtSignal(list) + thermistor_update = pyqtSignal(list) def __init__(self, parent, client, update_s): self.update_s = update_s @@ -98,6 +99,7 @@ class ClientWatcher(QObject): self.pwm_update.emit(await self.client.get_pwm()) self.report_update.emit(await self.client._command("report")) self.pid_update.emit(await self.client.get_pid()) + self.thermistor_update.emit(await self.client.get_steinhart_hart()) def start_watching(self): self.watch_task = asyncio.create_task(self.run()) @@ -136,6 +138,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.client_watcher.fan_update.connect(self.fan_update) self.client_watcher.report_update.connect(self.update_report) self.client_watcher.pid_update.connect(self.update_pid) + self.client_watcher.thermistor_update.connect(self.update_thermistor) self.report_apply_btn.clicked.connect( lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) @@ -290,6 +293,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): with QSignalBlocker(params[channel]): params[channel].child("Temperature PID").setValue(settings["pid_engaged"]) + @pyqtSlot(list) + def update_thermistor(self, sh_data): + for sh_param in sh_data: + channel = sh_param["channel"] + with QSignalBlocker(params[channel]): + params[channel].child("Thermistor Config", "T₀").setValue(sh_param["params"]["t0"] - 273.15) + params[channel].child("Thermistor Config", "R₀").setValue(sh_param["params"]["r0"]) + params[channel].child("Thermistor Config", "β").setValue(sh_param["params"]["b"]) + async def coro_main(): args = get_argparser().parse_args() From 9803a2d12b53e7d96827800611b82ba6de3dd96c Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 20 Jul 2023 13:49:23 +0800 Subject: [PATCH 085/247] Add pwm update --- pytec/tec_qt.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7108d3c..e88df6a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -138,6 +138,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.client_watcher.fan_update.connect(self.fan_update) 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.report_apply_btn.clicked.connect( lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) @@ -302,6 +303,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): params[channel].child("Thermistor Config", "R₀").setValue(sh_param["params"]["r0"]) 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(params[channel]): + params[channel].child("Output Config", "Max Voltage").setValue(pwm_params["max_v"]["value"]) + params[channel].child("Output Config", "Max Current").setValue(pwm_params["max_i_pos"]["value"]) + async def coro_main(): args = get_argparser().parse_args() From 863352d620412a1e722b6e73ad668664e8a709a5 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 20 Jul 2023 13:48:10 +0800 Subject: [PATCH 086/247] Add i_set --- pytec/tec_qt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e88df6a..7239e8a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -293,6 +293,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): channel = settings["channel"] with QSignalBlocker(params[channel]): params[channel].child("Temperature PID").setValue(settings["pid_engaged"]) + if not settings["pid_engaged"]: + params[channel].child("Constant Current").setValue(settings["i_set"]) @pyqtSlot(list) def update_thermistor(self, sh_data): From ae6f08247a40f6e2b0b2a096ff6127cb8736bc2b Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 1 Aug 2023 13:32:06 +0800 Subject: [PATCH 087/247] Add postfilter config --- pytec/tec_qt.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7239e8a..2b344af 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -41,6 +41,10 @@ THERMOSTAT_PARAMETERS = [[ '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}}']}, @@ -80,6 +84,7 @@ class ClientWatcher(QObject): 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 @@ -100,6 +105,7 @@ class ClientWatcher(QObject): self.report_update.emit(await self.client._command("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()) @@ -140,6 +146,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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()) ) @@ -313,6 +320,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): params[channel].child("Output Config", "Max Voltage").setValue(pwm_params["max_v"]["value"]) 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(params[channel]): + params[channel].child("Postfilter Config", "Rate").setValue(postfilter_params["rate"]) + async def coro_main(): args = get_argparser().parse_args() From 4be6d419f625348203d4d9e4813941ab416567fa Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 28 Jul 2023 17:46:12 +0800 Subject: [PATCH 088/247] Hide paramtree headers --- pytec/tec_qt.ui | 12 ++++++++++-- pytec/ui_tec_qt.py | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 4916d43..6cda65f 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -90,10 +90,18 @@ 2 - + + + true + + - + + + true + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 138cbfe..96cee43 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -45,9 +45,11 @@ class Ui_MainWindow(object): self.graphs_layout.setSpacing(2) self.graphs_layout.setObjectName("graphs_layout") self.ch1_tree = ParameterTree(parent=self.graph_group) + self.ch1_tree.setHeaderHidden(True) self.ch1_tree.setObjectName("ch1_tree") self.graphs_layout.addWidget(self.ch1_tree, 1, 0, 1, 1) self.ch0_tree = ParameterTree(parent=self.graph_group) + self.ch0_tree.setHeaderHidden(True) self.ch0_tree.setObjectName("ch0_tree") self.graphs_layout.addWidget(self.ch0_tree, 0, 0, 1, 1) self.ch1_t_graph = PlotWidget(parent=self.graph_group) From ac51476d595d9eed5d71859977fcdef1e63008c8 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 28 Jul 2023 11:34:34 +0800 Subject: [PATCH 089/247] Add save to flash paramtree item --- pytec/tec_qt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2b344af..7de4960 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -55,7 +55,8 @@ THERMOSTAT_PARAMETERS = [[ {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'suffix': '°C'}, {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, - ]} + ]}, + {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to flash', 'commands': [f'save {ch}']} ] for ch in range(2)] From 90df3ae7845b059937004a93e3be6aa52f928ef2 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 1 Aug 2023 13:32:55 +0800 Subject: [PATCH 090/247] Plus or minus symbol on swing --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7de4960..ec3be67 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -52,7 +52,7 @@ THERMOSTAT_PARAMETERS = [[ {'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, 'suffix': '°C'}, + {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'prefix': '±', 'suffix': '°C'}, {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, ]}, From fdf4c4f0d66f3f6036aa66df63f75d92fe1d7b21 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 6 Jul 2023 16:06:33 +0800 Subject: [PATCH 091/247] Plot temperature and current graphs - Have units - Samples are limited - pglive is used for better live graphs -- Also fixes bug with constantly updating normal pyqtgraphs where it will bug out if right-clicked on and context menu is brought up --Since pglive requires pyqtgraph == 0.13.3, upgrade pyqtgraph to that too. --- flake.nix | 27 ++++++++++++++++++-- pytec/tec_qt.py | 62 +++++++++++++++++++++++++++++++++++++++++++++- pytec/tec_qt.ui | 20 +++++++-------- pytec/ui_tec_qt.py | 12 ++++----- 4 files changed, 102 insertions(+), 19 deletions(-) diff --git a/flake.nix b/flake.nix index ccc5ad8..5c80712 100644 --- a/flake.nix +++ b/flake.nix @@ -68,13 +68,36 @@ propagatedBuildInputs = [ pkgs.python3Packages.pyqt6 ]; }; + pyqtgraph = pkgs.python3Packages.buildPythonPackage rec { + pname = "pyqtgraph"; + version = "0.13.3"; + format = "pyproject"; + src = pkgs.fetchPypi { + inherit pname version; + sha256 = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4="; + }; + propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ]; + }; + + pglive = pkgs.python3Packages.buildPythonPackage rec { + pname = "pglive"; + version = "0.7.2"; + format = "pyproject"; + src = pkgs.fetchPypi { + inherit pname version; + sha256 = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; + }; + buildInputs = [ pkgs.python3Packages.poetry-core ]; + propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ]; + }; + thermostat_gui = pkgs.python3Packages.buildPythonPackage { pname = "thermostat_gui"; version = "0.0.0"; src = "${self}/pytec"; nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; - propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync ]); + propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync pglive ]); dontWrapQtApps = true; postFixup = '' @@ -100,7 +123,7 @@ buildInputs = with pkgs; [ rust openocd dfu-util ] ++ (with python3Packages; [ - numpy matplotlib pyqtgraph setuptools pyqt6 qasync + numpy matplotlib pyqtgraph setuptools pyqt6 qasync pglive ]); }; defaultPackage.x86_64-linux = thermostat; diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index ec3be67..bfa362b 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,8 +1,12 @@ from PyQt6 import QtWidgets, QtGui from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot -from pyqtgraph import PlotWidget from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType 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 @@ -126,11 +130,17 @@ class ClientWatcher(QObject): 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_context_menu() self.fan_power_slider.valueChanged.connect(self.fan_set) @@ -138,11 +148,24 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._set_param_tree() + self.ch0_t_plot = LiveLinePlot() + self.ch0_i_plot = LiveLinePlot() + self.ch1_t_plot = LiveLinePlot() + self.ch1_i_plot = LiveLinePlot() + + 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.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.fan_pwm_recommended = False self.tec_client = Client() self.client_watcher = ClientWatcher(self, self.tec_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) @@ -175,6 +198,31 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.thermostat_settings.setMenu(self.menu) + 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}) + + 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.ch1_t_graph.addItem(self.ch1_t_plot) + self.ch1_i_graph.addItem(self.ch1_i_plot) + + def clear_graphs(self): + for connector in self.ch0_t_connector, self.ch0_i_connector, self.ch1_t_connector, self.ch1_i_connector: + connector.clear() + async def _on_connection_changed(self, result): self.graph_group.setEnabled(result) self.fan_group.setEnabled(result) @@ -191,6 +239,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.status_lbl.setText("Disconnected") self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") + self.clear_graphs() self.client_watcher.stop_watching() def _set_fan_pwm_warning(self): @@ -270,6 +319,17 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): await self._on_connection_changed(False) await self.tec_client.disconnect() + @pyqtSlot(list) + def plot(self, report): + for channel in range(2): + temperature = report[channel]['temperature'] + current = report[channel]['tec_i'] + time = report[channel]['time'] + + if temperature is not None and current 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) + @asyncSlot(object, object) async def send_command(self, param, changes): for param, change, data in changes: diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 6cda65f..73533f6 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -104,28 +104,28 @@ - + Channel 1 Temperature - + Channel 0 Temperature - + Channel 0 Current - + Channel 1 Current @@ -656,18 +656,18 @@ - - PlotWidget - QWidget -
pyqtgraph
- 1 -
ParameterTree QWidget
pyqtgraph.parametertree
1
+ + LivePlotWidget + QWidget +
pglive.sources.live_plot_widget
+ 1 +
diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 96cee43..40f54e6 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'tec_qt.ui' # -# Created by: PyQt6 UI code generator 6.4.2 +# Created by: PyQt6 UI code generator 6.5.0 # # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. @@ -52,16 +52,16 @@ class Ui_MainWindow(object): self.ch0_tree.setHeaderHidden(True) self.ch0_tree.setObjectName("ch0_tree") self.graphs_layout.addWidget(self.ch0_tree, 0, 0, 1, 1) - self.ch1_t_graph = PlotWidget(parent=self.graph_group) + self.ch1_t_graph = LivePlotWidget(parent=self.graph_group) self.ch1_t_graph.setObjectName("ch1_t_graph") self.graphs_layout.addWidget(self.ch1_t_graph, 1, 1, 1, 1) - self.ch0_t_graph = PlotWidget(parent=self.graph_group) + self.ch0_t_graph = LivePlotWidget(parent=self.graph_group) self.ch0_t_graph.setObjectName("ch0_t_graph") self.graphs_layout.addWidget(self.ch0_t_graph, 0, 1, 1, 1) - self.ch0_i_graph = PlotWidget(parent=self.graph_group) + self.ch0_i_graph = LivePlotWidget(parent=self.graph_group) self.ch0_i_graph.setObjectName("ch0_i_graph") self.graphs_layout.addWidget(self.ch0_i_graph, 0, 2, 1, 1) - self.ch1_i_graph = PlotWidget(parent=self.graph_group) + self.ch1_i_graph = LivePlotWidget(parent=self.graph_group) self.ch1_i_graph.setObjectName("ch1_i_graph") self.graphs_layout.addWidget(self.ch1_i_graph, 1, 2, 1, 1) self.graphs_layout.setColumnMinimumWidth(0, 100) @@ -306,7 +306,7 @@ class Ui_MainWindow(object): self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) self.report_apply_btn.setText(_translate("MainWindow", "Apply")) -from pyqtgraph import PlotWidget +from pglive.sources.live_plot_widget import LivePlotWidget from pyqtgraph.parametertree import ParameterTree From 001ce432e82b7f3187673ee9c48cb661f9006f06 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 19 Jul 2023 13:34:01 +0800 Subject: [PATCH 092/247] Add clear graphs context menu item --- pytec/tec_qt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index bfa362b..05913bc 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -198,6 +198,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.thermostat_settings.setMenu(self.menu) + clear = QtGui.QAction("Clear graphs", self.menu) + clear.triggered.connect(self.clear_graphs) + self.menu.addAction(clear) + self.menu.clear = clear + 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}) From 5c081b054772eaa8b6070e7035a7fddb3ac15b18 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 20 Jul 2023 15:54:28 +0800 Subject: [PATCH 093/247] Add samples box in menu --- pytec/tec_qt.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 05913bc..16f6b7d 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -203,6 +203,24 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu.addAction(clear) self.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.menu) + limit_samples.setDefaultWidget(self.samples_spinbox) + self.menu.addAction(limit_samples) + self.menu.limit_samples = limit_samples + + @pyqtSlot(int) + def set_max_samples(self, samples: int): + self.ch0_t_connector.max_points = samples + self.ch0_i_connector.max_points = samples + self.ch1_t_connector.max_points = samples + self.ch1_i_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}) From 728bce38b6b5633caf08154f952272d9dd4d1505 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 28 Jul 2023 17:46:26 +0800 Subject: [PATCH 094/247] Add crosshair for better read of values --- pytec/tec_qt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 16f6b7d..08dc007 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -227,6 +227,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): time_axis.showLabel() graph.setAxisItems({'bottom': time_axis}) + graph.add_crosshair(pg.mkPen(color='red', width=1), {'color': 'green'}) + for graph in self.ch0_t_graph, self.ch1_t_graph: temperature_axis = LiveAxis('left', text="Temperature", units="°C") temperature_axis.showLabel() From 64891231cd54e893f03e2dee092239a22722f000 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 09:47:24 +0800 Subject: [PATCH 095/247] Report mode functionality --- pytec/pytec/aioclient.py | 12 ++++++++++-- pytec/tec_qt.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 77b84a7..6248dac 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -11,6 +11,7 @@ class Client: self._writer = None self._connecting_task = None self._command_lock = asyncio.Lock() + self._report_mode_on = False async def connect(self, host='192.168.1.26', port=23, timeout=None): """Connect to the TEC with host and port, throws TimeoutError if @@ -167,9 +168,11 @@ class Client: 'pid_output': 2.067581958092247} """ await self._command("report mode", "on") + self._report_mode_on = True - while True: - line = await self._read_line() + while self._report_mode_on: + async with self._command_lock: + line = await self._read_line() if not line: break try: @@ -177,6 +180,11 @@ class Client: except json.decoder.JSONDecodeError: pass + await self._command("report mode", "off") + + def stop_report_mode(self): + self._report_mode_on = False + async def set_param(self, topic, channel, field="", value=""): """Set configuration parameters diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 08dc007..7f566ea 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -95,6 +95,7 @@ class ClientWatcher(QObject): self.update_s = update_s self.client = client self.watch_task = None + self.poll_for_report = True super().__init__(parent) async def run(self): @@ -107,7 +108,8 @@ class ClientWatcher(QObject): async def update_params(self): self.fan_update.emit(await self.client.fan()) self.pwm_update.emit(await self.client.get_pwm()) - self.report_update.emit(await self.client._command("report")) + if self.poll_for_report: + self.report_update.emit(await self.client._command("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()) @@ -124,6 +126,9 @@ class ClientWatcher(QObject): self.watch_task.cancel() self.watch_task = None + def set_report_polling(self, enabled: bool): + self.poll_for_report = enabled + @pyqtSlot(float) def set_update_s(self, update_s): self.update_s = update_s @@ -175,6 +180,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) + self.report_mode_task = None + if args.connect: if args.IP: self.ip_set_line.setText(args.IP) @@ -265,6 +272,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") self.clear_graphs() + self.report_box.setChecked(False) + await self.stop_report_mode() self.client_watcher.stop_watching() def _set_fan_pwm_warning(self): @@ -316,8 +325,29 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: await self.tec_client.set_param("fan", self.fan_power_slider.value()) + @asyncSlot(int) + async def on_report_box_stateChanged(self, enabled): + self.client_watcher.set_report_polling(not enabled) + if enabled: + self.report_mode_task = asyncio.create_task(self.report_mode()) + else: + self.tec_client.stop_report_mode() + await self.report_mode_task + self.report_mode_task = None + + async def report_mode(self): + async for report in self.tec_client.report_mode(): + self.client_watcher.report_update.emit(report) + + async def stop_report_mode(self): + if self.report_mode_task is not None: + self.tec_client.stop_report_mode() + await self.report_mode_task + self.report_mode_task = None + @asyncClose async def closeEvent(self, event): + await self.stop_report_mode() self.client_watcher.stop_watching() await self.tec_client.disconnect() From 8e98b62cfb3865ca66dc6774842624919e3a236e Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 2 Aug 2023 12:38:45 +0800 Subject: [PATCH 096/247] Add line at PID temp --- pytec/tec_qt.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7f566ea..5cf58cc 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -29,7 +29,7 @@ THERMOSTAT_PARAMETERS = [[ {'name': 'Temperature PID', 'type': 'bool', 'value': False, 'commands': [f'pwm {ch} pid'], 'payload': ch, 'children': [ {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, - 'suffix': '°C', 'commands': [f'pid {ch} target {{value}}']}, + 'suffix': '°C', 'commands': [f'pid {ch} target {{value}}'], 'payload': ch}, ]}, {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, @@ -158,6 +158,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.ch1_t_plot = LiveLinePlot() self.ch1_i_plot = LiveLinePlot() + 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) @@ -391,7 +396,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if param.name() == 'Temperature PID' and not data: ch = param.opts["payload"] await self.tec_client.set_param('pwm', ch, 'i_set', params[ch].child('Constant Current').value()) + line = getattr(self, f'ch{ch}_t_line') + line.setVisible(False) elif param.opts.get("commands", None) is not None: + if param.name() == 'Temperature PID': + getattr(self, f'ch{param.opts["payload"]}_t_line').setVisible(True) + elif param.name() == 'Set Temperature': + getattr(self, f'ch{param.opts["payload"]}_t_line').setValue(data) await asyncio.gather(*[self.tec_client._command(x.format(value=data)) for x in param.opts["commands"]]) def _set_param_tree(self): @@ -409,6 +420,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"]) if params[channel].child("Temperature PID").value(): 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): @@ -416,6 +428,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): channel = settings["channel"] with QSignalBlocker(params[channel]): 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"]: params[channel].child("Constant Current").setValue(settings["i_set"]) From a3d4bef68e6452eceb6fe9101e2e7e52669e4b5d Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 13:03:16 +0800 Subject: [PATCH 097/247] Crude removal of fan group from .ui file --- pytec/tec_qt.py | 60 ++++++++++++++++++- pytec/tec_qt.ui | 146 --------------------------------------------- pytec/ui_tec_qt.py | 56 ----------------- 3 files changed, 59 insertions(+), 203 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 5cf58cc..e1eb188 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,4 +1,4 @@ -from PyQt6 import QtWidgets, QtGui +from PyQt6 import QtWidgets, QtGui, QtCore from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType import pyqtgraph as pg @@ -203,6 +203,64 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu.addAction(port) self.menu.port = port + self.fan_group = QtWidgets.QWidget() + self.fan_group.setEnabled(False) + self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_group.setObjectName("fan_group") + self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group) + self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout_6.setSpacing(0) + self.horizontalLayout_6.setObjectName("horizontalLayout_6") + self.gan_layout = QtWidgets.QHBoxLayout() + self.gan_layout.setSpacing(9) + self.gan_layout.setObjectName("gan_layout") + 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.fan_pwm_warning.setObjectName("fan_pwm_warning") + self.gan_layout.addWidget(self.fan_pwm_warning) + 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.fan_lbl.setObjectName("fan_lbl") + 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.fan_power_slider.setObjectName("fan_power_slider") + 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.fan_auto_box.setObjectName("fan_auto_box") + self.gan_layout.addWidget(self.fan_auto_box) + self.horizontalLayout_6.addLayout(self.gan_layout) + + _translate = QtCore.QCoreApplication.translate + 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.menu) fan.setDefaultWidget(self.fan_group) self.menu.addAction(fan) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 73533f6..bf2a230 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -325,152 +325,6 @@
- - - - false - - - - 40 - 0 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 9 - - - - - - 16 - 0 - - - - - - - - - - - - 0 - 0 - - - - - 40 - 0 - - - - - 40 - 16777215 - - - - - 40 - 0 - - - - Adjust the fan - - - Fan: - - - - - - - - 0 - 0 - - - - - 200 - 0 - - - - - 200 - 16777215 - - - - - 200 - 0 - - - - 1 - - - 100 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 70 - 0 - - - - - 70 - 16777215 - - - - Auto - - - - - - - - diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 40f54e6..af152ad 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -153,59 +153,6 @@ class Ui_MainWindow(object): self.line_0.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) self.line_0.setObjectName("line_0") self.settings_layout.addWidget(self.line_0) - self.fan_group = QtWidgets.QWidget(parent=self.bottom_settings_group) - self.fan_group.setEnabled(False) - self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) - self.fan_group.setObjectName("fan_group") - self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group) - self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout_6.setSpacing(0) - self.horizontalLayout_6.setObjectName("horizontalLayout_6") - self.gan_layout = QtWidgets.QHBoxLayout() - self.gan_layout.setSpacing(9) - self.gan_layout.setObjectName("gan_layout") - 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.fan_pwm_warning.setObjectName("fan_pwm_warning") - self.gan_layout.addWidget(self.fan_pwm_warning) - 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.fan_lbl.setObjectName("fan_lbl") - 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.fan_power_slider.setObjectName("fan_power_slider") - 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.fan_auto_box.setObjectName("fan_auto_box") - self.gan_layout.addWidget(self.fan_auto_box) - self.horizontalLayout_6.addLayout(self.gan_layout) - self.settings_layout.addWidget(self.fan_group) self.line_1 = QtWidgets.QFrame(parent=self.bottom_settings_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) sizePolicy.setHorizontalStretch(0) @@ -299,9 +246,6 @@ class Ui_MainWindow(object): self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) - self.fan_lbl.setToolTip(_translate("MainWindow", "Adjust the fan")) - self.fan_lbl.setText(_translate("MainWindow", "Fan:")) - self.fan_auto_box.setText(_translate("MainWindow", "Auto")) self.report_lbl.setText(_translate("MainWindow", "Poll every: ")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) From 9aac5711871b801ec32b9f5a5650d369b272d67c Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 13:16:13 +0800 Subject: [PATCH 098/247] Remove leftover lines --- pytec/tec_qt.ui | 26 -------------------------- pytec/ui_tec_qt.py | 20 -------------------- 2 files changed, 46 deletions(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index bf2a230..521780d 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -312,32 +312,6 @@ - - - - - 0 - 0 - - - - Qt::Vertical - - - - - - - - 0 - 0 - - - - Qt::Vertical - - - diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index af152ad..026986f 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -143,26 +143,6 @@ class Ui_MainWindow(object): self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.thermostat_settings.setObjectName("thermostat_settings") self.settings_layout.addWidget(self.thermostat_settings) - self.line_0 = QtWidgets.QFrame(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.line_0.sizePolicy().hasHeightForWidth()) - self.line_0.setSizePolicy(sizePolicy) - self.line_0.setFrameShape(QtWidgets.QFrame.Shape.VLine) - self.line_0.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.line_0.setObjectName("line_0") - self.settings_layout.addWidget(self.line_0) - self.line_1 = QtWidgets.QFrame(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.line_1.sizePolicy().hasHeightForWidth()) - self.line_1.setSizePolicy(sizePolicy) - self.line_1.setFrameShape(QtWidgets.QFrame.Shape.VLine) - self.line_1.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.line_1.setObjectName("line_1") - self.settings_layout.addWidget(self.line_1) self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group) self.report_group.setEnabled(False) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) From cc1fdddddaf03642942c18d498e0cb7db2d3066b Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 13:17:59 +0800 Subject: [PATCH 099/247] Space out bottom bar properly --- pytec/tec_qt.ui | 13 +++++++++++++ pytec/ui_tec_qt.py | 2 ++ 2 files changed, 15 insertions(+) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 521780d..1306beb 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -312,6 +312,19 @@ + + + + Qt::Horizontal + + + + 40 + 20 + + + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 026986f..10fe02e 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -143,6 +143,8 @@ class Ui_MainWindow(object): self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.thermostat_settings.setObjectName("thermostat_settings") self.settings_layout.addWidget(self.thermostat_settings) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.settings_layout.addItem(spacerItem) self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group) self.report_group.setEnabled(False) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) From cbffb8d7009fca1ed8dda3ac7b2d0eada902278d Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 13:22:27 +0800 Subject: [PATCH 100/247] Crude relocation of port spinbox from .ui file --- pytec/tec_qt.py | 12 ++++++++++++ pytec/tec_qt.ui | 28 ---------------------------- pytec/ui_tec_qt.py | 12 ------------ 3 files changed, 12 insertions(+), 40 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e1eb188..0469ca5 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -198,6 +198,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu = QtWidgets.QMenu() self.menu.setTitle('Thermostat settings') + 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.setProperty("value", 23) + self.port_set_spin.setObjectName("port_set_spin") + port = QtWidgets.QWidgetAction(self.menu) port.setDefaultWidget(self.port_set_spin) self.menu.addAction(port) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 1306beb..3782127 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -212,34 +212,6 @@ - - - - - 0 - 0 - - - - - 70 - 0 - - - - - 70 - 16777215 - - - - 65535 - - - 23 - - - diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 10fe02e..9a7cb61 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -104,18 +104,6 @@ class Ui_MainWindow(object): self.ip_set_line.setClearButtonEnabled(True) self.ip_set_line.setObjectName("ip_set_line") self.settings_layout.addWidget(self.ip_set_line) - self.port_set_spin = QtWidgets.QSpinBox(parent=self.bottom_settings_group) - 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.setProperty("value", 23) - self.port_set_spin.setObjectName("port_set_spin") - self.settings_layout.addWidget(self.port_set_spin) self.connect_btn = QtWidgets.QPushButton(parent=self.bottom_settings_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) From 5732bc951fbbfca07c6033e42593d830373652e6 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 13:50:29 +0800 Subject: [PATCH 101/247] Split the settings and plotting menus --- pytec/tec_qt.py | 17 +++++++++++------ pytec/tec_qt.ui | 10 ++++++++++ pytec/ui_tec_qt.py | 5 +++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 0469ca5..4c09280 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -280,10 +280,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.thermostat_settings.setMenu(self.menu) - clear = QtGui.QAction("Clear graphs", self.menu) + 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.menu.addAction(clear) - self.menu.clear = clear + self.plot_menu.addAction(clear) + self.plot_menu.clear = clear self.samples_spinbox = QtWidgets.QSpinBox() self.samples_spinbox.setRange(2, 100000) @@ -291,10 +294,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.samples_spinbox.setValue(self.max_samples) self.samples_spinbox.valueChanged.connect(self.set_max_samples) - limit_samples = QtWidgets.QWidgetAction(self.menu) + limit_samples = QtWidgets.QWidgetAction(self.plot_menu) limit_samples.setDefaultWidget(self.samples_spinbox) - self.menu.addAction(limit_samples) - self.menu.limit_samples = limit_samples + self.plot_menu.addAction(limit_samples) + self.plot_menu.limit_samples = limit_samples + + self.toolButton_2.setMenu(self.plot_menu) @pyqtSlot(int) def set_max_samples(self, samples: int): diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 3782127..a59870e 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -284,6 +284,16 @@ + + + + ... + + + QToolButton::InstantPopup + + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 9a7cb61..2967d36 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -131,6 +131,10 @@ class Ui_MainWindow(object): self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.thermostat_settings.setObjectName("thermostat_settings") self.settings_layout.addWidget(self.thermostat_settings) + self.toolButton_2 = QtWidgets.QToolButton(parent=self.bottom_settings_group) + self.toolButton_2.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) + self.toolButton_2.setObjectName("toolButton_2") + self.settings_layout.addWidget(self.toolButton_2) spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) self.settings_layout.addItem(spacerItem) self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group) @@ -216,6 +220,7 @@ class Ui_MainWindow(object): self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) + self.toolButton_2.setText(_translate("MainWindow", "...")) self.report_lbl.setText(_translate("MainWindow", "Poll every: ")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) From 9fc38d461422d204b93936f1ba5f52560f55ab5b Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 14:02:55 +0800 Subject: [PATCH 102/247] Move fan throttling warning to the right --- pytec/tec_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 4c09280..4b258fe 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -226,11 +226,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.gan_layout = QtWidgets.QHBoxLayout() self.gan_layout.setSpacing(9) self.gan_layout.setObjectName("gan_layout") - 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.fan_pwm_warning.setObjectName("fan_pwm_warning") - self.gan_layout.addWidget(self.fan_pwm_warning) self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) @@ -266,6 +261,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215)) self.fan_auto_box.setObjectName("fan_auto_box") 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.fan_pwm_warning.setObjectName("fan_pwm_warning") + self.gan_layout.addWidget(self.fan_pwm_warning) self.horizontalLayout_6.addLayout(self.gan_layout) _translate = QtCore.QCoreApplication.translate From a1a94a9c99e6f77b1807b7637bf80b35196291c4 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 16:01:57 +0800 Subject: [PATCH 103/247] Move host selection into menu too --- pytec/tec_qt.py | 23 ++++++++++++++++++++++- pytec/tec_qt.ui | 34 ---------------------------------- pytec/ui_tec_qt.py | 14 -------------- 3 files changed, 22 insertions(+), 49 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 4b258fe..8e21c52 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -195,9 +195,31 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connect_btn.click() def _set_up_context_menu(self): + _translate = QtCore.QCoreApplication.translate + self.menu = QtWidgets.QMenu() self.menu.setTitle('Thermostat settings') + self.ip_set_line = QtWidgets.QLineEdit() + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.ip_set_line.sizePolicy().hasHeightForWidth()) + self.ip_set_line.setSizePolicy(sizePolicy) + self.ip_set_line.setMinimumSize(QtCore.QSize(160, 0)) + self.ip_set_line.setMaximumSize(QtCore.QSize(160, 16777215)) + self.ip_set_line.setMaxLength(15) + self.ip_set_line.setClearButtonEnabled(True) + self.ip_set_line.setObjectName("ip_set_line") + + self.ip_set_line.setText(_translate("MainWindow", "192.168.1.26")) + self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) + + host = QtWidgets.QWidgetAction(self.menu) + host.setDefaultWidget(self.ip_set_line) + self.menu.addAction(host) + self.menu.host = host + self.port_set_spin = QtWidgets.QSpinBox() sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed) sizePolicy.setHorizontalStretch(0) @@ -268,7 +290,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.gan_layout.addWidget(self.fan_pwm_warning) self.horizontalLayout_6.addLayout(self.gan_layout) - _translate = QtCore.QCoreApplication.translate self.fan_lbl.setToolTip(_translate("MainWindow", "Adjust the fan")) self.fan_lbl.setText(_translate("MainWindow", "Fan:")) self.fan_auto_box.setText(_translate("MainWindow", "Auto")) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index a59870e..9006243 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -178,40 +178,6 @@ - - - - - 0 - 0 - - - - - 160 - 0 - - - - - 160 - 16777215 - - - - 192.168.1.26 - - - 15 - - - IP:port for the Thermostat - - - true - - - diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 2967d36..31c4c83 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -92,18 +92,6 @@ class Ui_MainWindow(object): self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.settings_layout = QtWidgets.QHBoxLayout() self.settings_layout.setObjectName("settings_layout") - self.ip_set_line = QtWidgets.QLineEdit(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.ip_set_line.sizePolicy().hasHeightForWidth()) - self.ip_set_line.setSizePolicy(sizePolicy) - self.ip_set_line.setMinimumSize(QtCore.QSize(160, 0)) - self.ip_set_line.setMaximumSize(QtCore.QSize(160, 16777215)) - self.ip_set_line.setMaxLength(15) - self.ip_set_line.setClearButtonEnabled(True) - self.ip_set_line.setObjectName("ip_set_line") - self.settings_layout.addWidget(self.ip_set_line) self.connect_btn = QtWidgets.QPushButton(parent=self.bottom_settings_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) @@ -216,8 +204,6 @@ class Ui_MainWindow(object): self.ch0_t_graph.setTitle(_translate("MainWindow", "Channel 0 Temperature")) self.ch0_i_graph.setTitle(_translate("MainWindow", "Channel 0 Current")) self.ch1_i_graph.setTitle(_translate("MainWindow", "Channel 1 Current")) - self.ip_set_line.setText(_translate("MainWindow", "192.168.1.26")) - self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) self.toolButton_2.setText(_translate("MainWindow", "...")) From ca7c64c1155c328b48283880f807e4480db2cd86 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 16:43:24 +0800 Subject: [PATCH 104/247] Put connection details in connection button menu --- pytec/tec_qt.py | 21 +++++++++++++-------- pytec/tec_qt.ui | 8 +++++++- pytec/ui_tec_qt.py | 4 +++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8e21c52..523ff42 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -197,8 +197,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _set_up_context_menu(self): _translate = QtCore.QCoreApplication.translate - self.menu = QtWidgets.QMenu() - self.menu.setTitle('Thermostat settings') + self.connection_menu = QtWidgets.QMenu() + self.connection_menu.setTitle('Connection Settings') self.ip_set_line = QtWidgets.QLineEdit() sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) @@ -215,10 +215,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.ip_set_line.setText(_translate("MainWindow", "192.168.1.26")) self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) - host = QtWidgets.QWidgetAction(self.menu) + host = QtWidgets.QWidgetAction(self.connection_menu) host.setDefaultWidget(self.ip_set_line) - self.menu.addAction(host) - self.menu.host = host + 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) @@ -232,10 +232,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.port_set_spin.setProperty("value", 23) self.port_set_spin.setObjectName("port_set_spin") - port = QtWidgets.QWidgetAction(self.menu) + port = QtWidgets.QWidgetAction(self.connection_menu) port.setDefaultWidget(self.port_set_spin) - self.menu.addAction(port) - self.menu.port = port + self.connection_menu.addAction(port) + self.connection_menu.port = port + + self.connect_btn.setMenu(self.connection_menu) + + self.menu = QtWidgets.QMenu() + self.menu.setTitle('Thermostat settings') self.fan_group = QtWidgets.QWidget() self.fan_group.setEnabled(False) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 9006243..a9e45be 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -179,7 +179,7 @@ - + 0 @@ -207,6 +207,12 @@ Connect + + QToolButton::MenuButtonPopup + + + Qt::ToolButtonFollowStyle + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 31c4c83..8c5a61d 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -92,7 +92,7 @@ class Ui_MainWindow(object): self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.settings_layout = QtWidgets.QHBoxLayout() self.settings_layout.setObjectName("settings_layout") - self.connect_btn = QtWidgets.QPushButton(parent=self.bottom_settings_group) + self.connect_btn = QtWidgets.QToolButton(parent=self.bottom_settings_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -101,6 +101,8 @@ class Ui_MainWindow(object): self.connect_btn.setMinimumSize(QtCore.QSize(100, 0)) self.connect_btn.setMaximumSize(QtCore.QSize(100, 16777215)) self.connect_btn.setBaseSize(QtCore.QSize(100, 0)) + self.connect_btn.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup) + self.connect_btn.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonFollowStyle) self.connect_btn.setObjectName("connect_btn") self.settings_layout.addWidget(self.connect_btn) self.status_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group) From 998d999b59b79acaa3b3efe8a1ca59a62034227f Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 17:44:15 +0800 Subject: [PATCH 105/247] Save the entire hw_rev data Not just fan_pwm_recommended --- pytec/tec_qt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 523ff42..5bd1b00 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -170,7 +170,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.fan_pwm_recommended = False + self.hw_rev_data = None self.tec_client = Client() self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) @@ -394,9 +394,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _status(self, hw_rev_d: dict): logging.debug(hw_rev_d) + self.hw_rev_data = 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"]) - self.fan_pwm_recommended = hw_rev_d["settings"]["fan_pwm_recommended"] @pyqtSlot(dict) def fan_update(self, fan_settings: dict): @@ -407,7 +407,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.fan_pwm_recommended: + if not self.hw_rev_data["settings"]["fan_pwm_recommended"]: self._set_fan_pwm_warning() @asyncSlot(int) @@ -418,7 +418,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(False) await self.tec_client.set_param("fan", value) - if not self.fan_pwm_recommended: + if not self.hw_rev_data["settings"]["fan_pwm_recommended"]: self._set_fan_pwm_warning() @asyncSlot(int) From 8f31380d5282edb4b8146a3e1c85f81712a05c2f Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 26 Jul 2023 17:46:21 +0800 Subject: [PATCH 106/247] Reset button --- pytec/pytec/aioclient.py | 11 +++++++++++ pytec/tec_qt.py | 11 +++++++++++ pytec/tec_qt.ui | 11 +++++++++++ pytec/ui_tec_qt.py | 5 +++++ 4 files changed, 38 insertions(+) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 6248dac..d07ebce 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -223,3 +223,14 @@ class Client: async def fan(self): """Get Thermostat current fan settings""" return await self._command("fan") + + async def reset(self): + """Reset the Thermostat + + The client is disconnected as the TCP session is terminated. + """ + async with self._command_lock: + self._writer.write("reset\n".encode('utf-8')) + await self._writer.drain() + + await self.disconnect() diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 5bd1b00..ed3d189 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -304,6 +304,17 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu.addAction(fan) self.menu.fan = fan + @asyncSlot(bool) + async def reset_thermostat(_): + await self._on_connection_changed(False) + await self.tec_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.menu.addAction(self.actionReset) + self.thermostat_settings.setMenu(self.menu) self.plot_menu = QtWidgets.QMenu() diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index a9e45be..aa28348 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -449,6 +449,17 @@ + + + Reset + + + Reset the Thermostat + + + QAction::NoRole + +
diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 8c5a61d..a6b44b9 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -195,6 +195,9 @@ class Ui_MainWindow(object): self.main_layout.addWidget(self.bottom_settings_group) self.gridLayout_2.addLayout(self.main_layout, 0, 1, 1, 1) MainWindow.setCentralWidget(self.main_widget) + self.actionReset = QtGui.QAction(parent=MainWindow) + self.actionReset.setMenuRole(QtGui.QAction.MenuRole.NoRole) + self.actionReset.setObjectName("actionReset") self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -213,6 +216,8 @@ class Ui_MainWindow(object): self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) self.report_apply_btn.setText(_translate("MainWindow", "Apply")) + self.actionReset.setText(_translate("MainWindow", "Reset")) + self.actionReset.setToolTip(_translate("MainWindow", "Reset the Thermostat")) from pglive.sources.live_plot_widget import LivePlotWidget from pyqtgraph.parametertree import ParameterTree From 2d341df23c1b06e4d224f5ba925a75e1c1b7d314 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 27 Jul 2023 13:24:54 +0800 Subject: [PATCH 107/247] Use _on_connection_changed(False) --- pytec/tec_qt.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index ed3d189..7334265 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -464,8 +464,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncClose async def closeEvent(self, event): - await self.stop_report_mode() - self.client_watcher.stop_watching() + await self._on_connection_changed(False) await self.tec_client.disconnect() @asyncSlot() From a9c0106c465d440fe12b417f7e2484405adf20e6 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 28 Jul 2023 10:42:13 +0800 Subject: [PATCH 108/247] Add DFU mode menu option Does nothing for now --- pytec/tec_qt.py | 2 ++ pytec/tec_qt.ui | 11 +++++++++++ pytec/ui_tec_qt.py | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7334265..a46daed 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -315,6 +315,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.actionReset.triggered.connect(reset_thermostat) self.menu.addAction(self.actionReset) + self.menu.addAction(self.actionEnter_DFU_Mode) + self.thermostat_settings.setMenu(self.menu) self.plot_menu = QtWidgets.QMenu() diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index aa28348..c894598 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -460,6 +460,17 @@ QAction::NoRole + + + Enter DFU Mode + + + Reset thermostat and enter USB device firmware update (DFU) mode + + + QAction::NoRole + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index a6b44b9..7c10a57 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -198,6 +198,9 @@ class Ui_MainWindow(object): self.actionReset = QtGui.QAction(parent=MainWindow) self.actionReset.setMenuRole(QtGui.QAction.MenuRole.NoRole) self.actionReset.setObjectName("actionReset") + self.actionEnter_DFU_Mode = QtGui.QAction(parent=MainWindow) + self.actionEnter_DFU_Mode.setMenuRole(QtGui.QAction.MenuRole.NoRole) + self.actionEnter_DFU_Mode.setObjectName("actionEnter_DFU_Mode") self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -218,6 +221,8 @@ class Ui_MainWindow(object): self.report_apply_btn.setText(_translate("MainWindow", "Apply")) self.actionReset.setText(_translate("MainWindow", "Reset")) self.actionReset.setToolTip(_translate("MainWindow", "Reset the Thermostat")) + self.actionEnter_DFU_Mode.setText(_translate("MainWindow", "Enter DFU Mode")) + self.actionEnter_DFU_Mode.setToolTip(_translate("MainWindow", "Reset thermostat and enter USB device firmware update (DFU) mode")) from pglive.sources.live_plot_widget import LivePlotWidget from pyqtgraph.parametertree import ParameterTree From 9364c9b1872d3f3eab2021ed64397ddf41d32032 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 28 Jul 2023 10:45:50 +0800 Subject: [PATCH 109/247] Add network settings menu option Also does nothing for now --- pytec/tec_qt.py | 1 + pytec/tec_qt.ui | 11 +++++++++++ pytec/ui_tec_qt.py | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a46daed..d5cf3d8 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -316,6 +316,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu.addAction(self.actionReset) self.menu.addAction(self.actionEnter_DFU_Mode) + self.menu.addAction(self.actionNetwork_Settings) self.thermostat_settings.setMenu(self.menu) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index c894598..6962c70 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -471,6 +471,17 @@ QAction::NoRole + + + Network Settings + + + Configure IPv4 address, netmask length, and optional default gateway + + + QAction::NoRole + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 7c10a57..739cbb3 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -201,6 +201,9 @@ class Ui_MainWindow(object): self.actionEnter_DFU_Mode = QtGui.QAction(parent=MainWindow) self.actionEnter_DFU_Mode.setMenuRole(QtGui.QAction.MenuRole.NoRole) self.actionEnter_DFU_Mode.setObjectName("actionEnter_DFU_Mode") + self.actionNetwork_Settings = QtGui.QAction(parent=MainWindow) + self.actionNetwork_Settings.setMenuRole(QtGui.QAction.MenuRole.NoRole) + self.actionNetwork_Settings.setObjectName("actionNetwork_Settings") self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -223,6 +226,8 @@ class Ui_MainWindow(object): self.actionReset.setToolTip(_translate("MainWindow", "Reset the Thermostat")) self.actionEnter_DFU_Mode.setText(_translate("MainWindow", "Enter DFU Mode")) self.actionEnter_DFU_Mode.setToolTip(_translate("MainWindow", "Reset thermostat and enter USB device firmware update (DFU) mode")) + self.actionNetwork_Settings.setText(_translate("MainWindow", "Network Settings")) + self.actionNetwork_Settings.setToolTip(_translate("MainWindow", "Configure IPv4 address, netmask length, and optional default gateway")) from pglive.sources.live_plot_widget import LivePlotWidget from pyqtgraph.parametertree import ParameterTree From 1be874f6a75258f226d381bfcdfb22498fe00bc5 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 28 Jul 2023 10:51:27 +0800 Subject: [PATCH 110/247] Add about thermostat menu item Meant to display hardware rev stuff, does nothing right now --- pytec/tec_qt.py | 1 + pytec/tec_qt.ui | 11 +++++++++++ pytec/ui_tec_qt.py | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index d5cf3d8..632643c 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -317,6 +317,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu.addAction(self.actionEnter_DFU_Mode) self.menu.addAction(self.actionNetwork_Settings) + self.menu.addAction(self.actionAbout_Thermostat) self.thermostat_settings.setMenu(self.menu) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 6962c70..84ccde9 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -482,6 +482,17 @@ QAction::NoRole + + + About Thermostat + + + Show Thermostat hardware revision, and settings related to i + + + QAction::NoRole + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 739cbb3..931fe57 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -204,6 +204,9 @@ class Ui_MainWindow(object): self.actionNetwork_Settings = QtGui.QAction(parent=MainWindow) self.actionNetwork_Settings.setMenuRole(QtGui.QAction.MenuRole.NoRole) self.actionNetwork_Settings.setObjectName("actionNetwork_Settings") + self.actionAbout_Thermostat = QtGui.QAction(parent=MainWindow) + self.actionAbout_Thermostat.setMenuRole(QtGui.QAction.MenuRole.NoRole) + self.actionAbout_Thermostat.setObjectName("actionAbout_Thermostat") self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -228,6 +231,8 @@ class Ui_MainWindow(object): self.actionEnter_DFU_Mode.setToolTip(_translate("MainWindow", "Reset thermostat and enter USB device firmware update (DFU) mode")) self.actionNetwork_Settings.setText(_translate("MainWindow", "Network Settings")) self.actionNetwork_Settings.setToolTip(_translate("MainWindow", "Configure IPv4 address, netmask length, and optional default gateway")) + self.actionAbout_Thermostat.setText(_translate("MainWindow", "About Thermostat")) + self.actionAbout_Thermostat.setToolTip(_translate("MainWindow", "Show Thermostat hardware revision, and settings related to i")) from pglive.sources.live_plot_widget import LivePlotWidget from pyqtgraph.parametertree import ParameterTree From efa814a0d3e3e0af35c04dacc8bffc6f97548d90 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 28 Jul 2023 11:28:36 +0800 Subject: [PATCH 111/247] Add load and save config menu items --- pytec/tec_qt.py | 2 ++ pytec/tec_qt.ui | 22 ++++++++++++++++++++++ pytec/ui_tec_qt.py | 10 ++++++++++ 3 files changed, 34 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 632643c..470fb28 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -318,6 +318,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu.addAction(self.actionEnter_DFU_Mode) self.menu.addAction(self.actionNetwork_Settings) self.menu.addAction(self.actionAbout_Thermostat) + self.menu.addAction(self.actionLoad_all_configs) + self.menu.addAction(self.actionSave_all_configs) self.thermostat_settings.setMenu(self.menu) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 84ccde9..b3e49cd 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -493,6 +493,28 @@ QAction::NoRole + + + Load all configs + + + Restore configuration for all channels from flash + + + QAction::NoRole + + + + + Save all configs + + + Save configuration for all channels to flash + + + QAction::NoRole + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 931fe57..9d0a2cf 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -207,6 +207,12 @@ class Ui_MainWindow(object): self.actionAbout_Thermostat = QtGui.QAction(parent=MainWindow) self.actionAbout_Thermostat.setMenuRole(QtGui.QAction.MenuRole.NoRole) self.actionAbout_Thermostat.setObjectName("actionAbout_Thermostat") + self.actionLoad_all_configs = QtGui.QAction(parent=MainWindow) + self.actionLoad_all_configs.setMenuRole(QtGui.QAction.MenuRole.NoRole) + self.actionLoad_all_configs.setObjectName("actionLoad_all_configs") + self.actionSave_all_configs = QtGui.QAction(parent=MainWindow) + self.actionSave_all_configs.setMenuRole(QtGui.QAction.MenuRole.NoRole) + self.actionSave_all_configs.setObjectName("actionSave_all_configs") self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -233,6 +239,10 @@ class Ui_MainWindow(object): self.actionNetwork_Settings.setToolTip(_translate("MainWindow", "Configure IPv4 address, netmask length, and optional default gateway")) self.actionAbout_Thermostat.setText(_translate("MainWindow", "About Thermostat")) self.actionAbout_Thermostat.setToolTip(_translate("MainWindow", "Show Thermostat hardware revision, and settings related to i")) + self.actionLoad_all_configs.setText(_translate("MainWindow", "Load all configs")) + self.actionLoad_all_configs.setToolTip(_translate("MainWindow", "Restore configuration for all channels from flash")) + self.actionSave_all_configs.setText(_translate("MainWindow", "Save all configs")) + self.actionSave_all_configs.setToolTip(_translate("MainWindow", "Save configuration for all channels to flash")) from pglive.sources.live_plot_widget import LivePlotWidget from pyqtgraph.parametertree import ParameterTree From d52aafd7f6fe0115ba2fd3a7e794a778291f65fe Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 00:34:09 +0800 Subject: [PATCH 112/247] Add timeout to connect call --- pytec/pytec/aioclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index d07ebce..0095c69 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -23,7 +23,7 @@ class Client: if connected: return """ - self._connecting_task = asyncio.create_task(asyncio.open_connection(host, port)) + self._connecting_task = asyncio.create_task(asyncio.wait_for(asyncio.open_connection(host, port), timeout)) try: self._reader, self._writer = await self._connecting_task except asyncio.CancelledError: From b32062d8551dc18dc8dafdcb7f85553d9f77f5cb Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 12:33:00 +0800 Subject: [PATCH 113/247] More elegant connection stopping --- pytec/pytec/aioclient.py | 24 +++++++++++++++--------- pytec/tec_qt.py | 9 +++++---- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 0095c69..cf016ce 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -5,6 +5,9 @@ import logging class CommandError(Exception): pass +class StoppedConnecting(Exception): + pass + class Client: def __init__(self): self._reader = None @@ -14,25 +17,28 @@ class Client: self._report_mode_on = False async def connect(self, host='192.168.1.26', port=23, timeout=None): - """Connect to the TEC with host and port, throws TimeoutError if - unable to connect. Returns True if not cancelled with disconnect. + """Connect to the Thermostat with host and port. + Throws StoppedConnecting if disconnect was called while connecting. + Throws asyncio.TimeoutError if timeout was exceeded. Example:: - client = aioclient.Client() - connected = await client.connect() - if connected: - return + client = Client() + try: + await client.connect() + except StoppedConnecting: + print("Stopped connecting") """ - self._connecting_task = asyncio.create_task(asyncio.wait_for(asyncio.open_connection(host, port), timeout)) + self._connecting_task = asyncio.create_task( + asyncio.wait_for(asyncio.open_connection(host, port), timeout) + ) try: self._reader, self._writer = await self._connecting_task except asyncio.CancelledError: - return False + raise StoppedConnecting finally: self._connecting_task = None await self._check_zero_limits() - return True def is_connecting(self): """Returns True if client is connecting""" diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 470fb28..f3af821 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -11,7 +11,7 @@ import sys import argparse import logging import asyncio -from pytec.aioclient import Client +from pytec.aioclient import Client, StoppedConnecting import qasync from qasync import asyncSlot, asyncClose @@ -483,15 +483,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.ip_set_line.setEnabled(False) self.port_set_spin.setEnabled(False) - connected = await self.tec_client.connect(host=ip, port=port, timeout=30) - if not connected: + try: + await self.tec_client.connect(host=ip, port=port, timeout=30) + except StoppedConnecting: return await self._on_connection_changed(True) else: await self._on_connection_changed(False) await self.tec_client.disconnect() - except (OSError, TimeoutError) as e: + except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11 logging.error(f"Failed communicating to {ip}:{port}: {e}") await self._on_connection_changed(False) await self.tec_client.disconnect() From 8ff08c1539f08f2cfd5bb5573d86783a3a9a8e50 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 13:06:24 +0800 Subject: [PATCH 114/247] Not just ip, can put domain name too, or "host" --- pytec/tec_qt.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index f3af821..3aa8e20 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -189,7 +189,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if args.connect: if args.IP: - self.ip_set_line.setText(args.IP) + self.host_set_line.setText(args.IP) if args.PORT: self.port_set_spin.setValue(int(args.PORT)) self.connect_btn.click() @@ -200,23 +200,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connection_menu = QtWidgets.QMenu() self.connection_menu.setTitle('Connection Settings') - self.ip_set_line = QtWidgets.QLineEdit() + 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.ip_set_line.sizePolicy().hasHeightForWidth()) - self.ip_set_line.setSizePolicy(sizePolicy) - self.ip_set_line.setMinimumSize(QtCore.QSize(160, 0)) - self.ip_set_line.setMaximumSize(QtCore.QSize(160, 16777215)) - self.ip_set_line.setMaxLength(15) - self.ip_set_line.setClearButtonEnabled(True) - self.ip_set_line.setObjectName("ip_set_line") + 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.setObjectName("host_set_line") - self.ip_set_line.setText(_translate("MainWindow", "192.168.1.26")) - self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) + self.host_set_line.setText(_translate("MainWindow", "192.168.1.26")) + self.host_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) host = QtWidgets.QWidgetAction(self.connection_menu) - host.setDefaultWidget(self.ip_set_line) + host.setDefaultWidget(self.host_set_line) self.connection_menu.addAction(host) self.connection_menu.host = host @@ -383,7 +383,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_group.setEnabled(result) self.report_group.setEnabled(result) - self.ip_set_line.setEnabled(not 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: @@ -475,12 +475,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot() async def on_connect_btn_clicked(self): - ip, port = self.ip_set_line.text(), self.port_set_spin.value() + ip, port = self.host_set_line.text(), self.port_set_spin.value() try: if not (self.tec_client.is_connecting() or self.tec_client.is_connected()): self.status_lbl.setText("Connecting...") self.connect_btn.setText("Stop") - self.ip_set_line.setEnabled(False) + self.host_set_line.setEnabled(False) self.port_set_spin.setEnabled(False) try: From 0434b08abc9b56271fb20e6c27cef5e4531e3bf7 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 13:10:57 +0800 Subject: [PATCH 115/247] Don't translate ip --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 3aa8e20..57910ff 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -212,7 +212,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.host_set_line.setClearButtonEnabled(True) self.host_set_line.setObjectName("host_set_line") - self.host_set_line.setText(_translate("MainWindow", "192.168.1.26")) + self.host_set_line.setText("192.168.1.26") self.host_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) host = QtWidgets.QWidgetAction(self.connection_menu) From fa8f1ebf10f24b00910a0677149e513e2e2cd4a1 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 13:11:36 +0800 Subject: [PATCH 116/247] No :port --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 57910ff..5f882cb 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -213,7 +213,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.host_set_line.setObjectName("host_set_line") self.host_set_line.setText("192.168.1.26") - self.host_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) + self.host_set_line.setPlaceholderText(_translate("MainWindow", "IP for the Thermostat")) host = QtWidgets.QWidgetAction(self.connection_menu) host.setDefaultWidget(self.host_set_line) From 1ae44d6b82cc45d959bb5c4b999b227f2d54c58e Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 13:22:35 +0800 Subject: [PATCH 117/247] Give proper names to settings buttons --- pytec/tec_qt.py | 2 +- pytec/tec_qt.ui | 2 +- pytec/ui_tec_qt.py | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 5f882cb..a6ef65d 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -342,7 +342,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.plot_menu.addAction(limit_samples) self.plot_menu.limit_samples = limit_samples - self.toolButton_2.setMenu(self.plot_menu) + self.plot_settings.setMenu(self.plot_menu) @pyqtSlot(int) def set_max_samples(self, samples: int): diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index b3e49cd..b60597c 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -257,7 +257,7 @@
- + ... diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 9d0a2cf..03fcd21 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -121,10 +121,10 @@ class Ui_MainWindow(object): self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.thermostat_settings.setObjectName("thermostat_settings") self.settings_layout.addWidget(self.thermostat_settings) - self.toolButton_2 = QtWidgets.QToolButton(parent=self.bottom_settings_group) - self.toolButton_2.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) - self.toolButton_2.setObjectName("toolButton_2") - self.settings_layout.addWidget(self.toolButton_2) + self.plot_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group) + self.plot_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) + self.plot_settings.setObjectName("plot_settings") + self.settings_layout.addWidget(self.plot_settings) spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) self.settings_layout.addItem(spacerItem) self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group) @@ -226,7 +226,7 @@ class Ui_MainWindow(object): self.ch1_i_graph.setTitle(_translate("MainWindow", "Channel 1 Current")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) - self.toolButton_2.setText(_translate("MainWindow", "...")) + self.plot_settings.setText(_translate("MainWindow", "...")) self.report_lbl.setText(_translate("MainWindow", "Poll every: ")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) From 1f0e74bf9fcb4f103ac33289b976b48a3c6d98ac Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 13:25:37 +0800 Subject: [PATCH 118/247] Don't enabled thermostat settings before connect --- pytec/tec_qt.py | 1 + pytec/tec_qt.ui | 3 +++ pytec/ui_tec_qt.py | 1 + 3 files changed, 5 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a6ef65d..207f214 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -382,6 +382,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.graph_group.setEnabled(result) self.fan_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) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index b60597c..d33c3b4 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -248,6 +248,9 @@ + + false + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 03fcd21..5ffcaa1 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -117,6 +117,7 @@ class Ui_MainWindow(object): self.status_lbl.setObjectName("status_lbl") self.settings_layout.addWidget(self.status_lbl) self.thermostat_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group) + self.thermostat_settings.setEnabled(False) self.thermostat_settings.setText("⚙") self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.thermostat_settings.setObjectName("thermostat_settings") From 34ed3cf39ad771f0c5a927809a6ca2df9d7fff9c Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 13:30:14 +0800 Subject: [PATCH 119/247] Add tooltip to settings buttons --- pytec/tec_qt.ui | 6 ++++++ pytec/ui_tec_qt.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index d33c3b4..d6a3f71 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -154,6 +154,9 @@ 40 + + Thermostat Settings + QFrame::StyledPanel @@ -263,6 +266,9 @@ ... + + Plot Settings + QToolButton::InstantPopup diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 5ffcaa1..4431f0e 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -225,9 +225,11 @@ class Ui_MainWindow(object): self.ch0_t_graph.setTitle(_translate("MainWindow", "Channel 0 Temperature")) self.ch0_i_graph.setTitle(_translate("MainWindow", "Channel 0 Current")) self.ch1_i_graph.setTitle(_translate("MainWindow", "Channel 1 Current")) + self.bottom_settings_group.setToolTip(_translate("MainWindow", "Thermostat Settings")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) self.plot_settings.setText(_translate("MainWindow", "...")) + self.plot_settings.setToolTip(_translate("MainWindow", "Plot Settings")) self.report_lbl.setText(_translate("MainWindow", "Poll every: ")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) From d7c0219456ab0f334f5368880ced5424c8351d90 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 16:14:02 +0800 Subject: [PATCH 120/247] Use graph emoji for plot settings --- pytec/tec_qt.ui | 4 ++-- pytec/ui_tec_qt.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index d6a3f71..16a5699 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -264,11 +264,11 @@ - - ... Plot Settings + + 📉 QToolButton::InstantPopup diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 4431f0e..a9ac249 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -228,8 +228,8 @@ class Ui_MainWindow(object): self.bottom_settings_group.setToolTip(_translate("MainWindow", "Thermostat Settings")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) - self.plot_settings.setText(_translate("MainWindow", "...")) self.plot_settings.setToolTip(_translate("MainWindow", "Plot Settings")) + self.plot_settings.setText(_translate("MainWindow", "📉")) self.report_lbl.setText(_translate("MainWindow", "Poll every: ")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) From 7a727cb011ed8c18c24d9009ada88fc6aa6ef6fe Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 16:14:14 +0800 Subject: [PATCH 121/247] Add about thermostat window --- pytec/tec_qt.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 207f214..b394400 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -317,10 +317,38 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.menu.addAction(self.actionEnter_DFU_Mode) self.menu.addAction(self.actionNetwork_Settings) - self.menu.addAction(self.actionAbout_Thermostat) self.menu.addAction(self.actionLoad_all_configs) self.menu.addAction(self.actionSave_all_configs) + def about_thermostat(): + QtWidgets.QMessageBox.about( + self, + _translate("MainWindow","About Thermostat"), + f""" +

Sinara 8451 Thermostat v{self.hw_rev_d['rev']['major']}.{self.hw_rev_d['rev']['minor']}

+ +
+ +

Settings:

+ 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']} +
+ Fan PWM range: + {self.hw_rev_d['settings']['min_fan_pwm']} – {self.hw_rev_d['settings']['max_fan_pwm']} +
+ Fan PWM frequency: {self.hw_rev_d['settings']['fan_pwm_freq_hz']} Hz +
+ Fan available: {self.hw_rev_d['settings']['fan_available']} +
+ Fan PWM recommended: {self.hw_rev_d['settings']['fan_pwm_recommended']} + """ + ) + + self.actionAbout_Thermostat.triggered.connect(about_thermostat) + self.menu.addAction(self.actionAbout_Thermostat) + self.thermostat_settings.setMenu(self.menu) self.plot_menu = QtWidgets.QMenu() From 04437784569f3f3729f5f980848dd1b65b536467 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 16:19:07 +0800 Subject: [PATCH 122/247] Split menu setup method per menu --- pytec/tec_qt.py | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b394400..996c055 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -146,7 +146,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.max_samples = self.DEFAULT_MAX_SAMPLES - self._set_up_context_menu() + self._set_up_connection_menu() + self._set_up_thermostat_menu() + self._set_up_plot_menu() self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) @@ -194,7 +196,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.port_set_spin.setValue(int(args.PORT)) self.connect_btn.click() - def _set_up_context_menu(self): + def _set_up_connection_menu(self): _translate = QtCore.QCoreApplication.translate self.connection_menu = QtWidgets.QMenu() @@ -239,8 +241,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connect_btn.setMenu(self.connection_menu) - self.menu = QtWidgets.QMenu() - self.menu.setTitle('Thermostat settings') + 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) @@ -299,10 +304,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_lbl.setText(_translate("MainWindow", "Fan:")) self.fan_auto_box.setText(_translate("MainWindow", "Auto")) - fan = QtWidgets.QWidgetAction(self.menu) + fan = QtWidgets.QWidgetAction(self.thermostat_menu) fan.setDefaultWidget(self.fan_group) - self.menu.addAction(fan) - self.menu.fan = fan + self.thermostat_menu.addAction(fan) + self.thermostat_menu.fan = fan @asyncSlot(bool) async def reset_thermostat(_): @@ -313,12 +318,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connect_btn.click() # Reconnect self.actionReset.triggered.connect(reset_thermostat) - self.menu.addAction(self.actionReset) + self.thermostat_menu.addAction(self.actionReset) - self.menu.addAction(self.actionEnter_DFU_Mode) - self.menu.addAction(self.actionNetwork_Settings) - self.menu.addAction(self.actionLoad_all_configs) - self.menu.addAction(self.actionSave_all_configs) + self.thermostat_menu.addAction(self.actionEnter_DFU_Mode) + self.thermostat_menu.addAction(self.actionNetwork_Settings) + self.thermostat_menu.addAction(self.actionLoad_all_configs) + self.thermostat_menu.addAction(self.actionSave_all_configs) def about_thermostat(): QtWidgets.QMessageBox.about( @@ -347,9 +352,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) self.actionAbout_Thermostat.triggered.connect(about_thermostat) - self.menu.addAction(self.actionAbout_Thermostat) + self.thermostat_menu.addAction(self.actionAbout_Thermostat) - self.thermostat_settings.setMenu(self.menu) + 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") From 26fdc951bc7dcd7d9d0d5b650c3f8a0167b7baa1 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 16:21:15 +0800 Subject: [PATCH 123/247] Move fan signal connection into menu setup --- pytec/tec_qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 996c055..f9eb7b1 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -150,9 +150,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._set_up_thermostat_menu() self._set_up_plot_menu() - self.fan_power_slider.valueChanged.connect(self.fan_set) - self.fan_auto_box.stateChanged.connect(self.fan_auto_set) - self._set_param_tree() self.ch0_t_plot = LiveLinePlot() @@ -300,6 +297,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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")) From 9291160798a392ad843ebdfd27a2b9db438f70b6 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 31 Jul 2023 16:36:48 +0800 Subject: [PATCH 124/247] Change name of tec_client --- pytec/tec_qt.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index f9eb7b1..5dc718e 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -171,8 +171,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.hw_rev_data = None - self.tec_client = Client() - self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) + self.client = Client() + 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) @@ -312,7 +312,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(bool) async def reset_thermostat(_): await self._on_connection_changed(False) - await self.tec_client.reset() + await self.client.reset() await asyncio.sleep(0.1) # Wait for the reset to start self.connect_btn.click() # Reconnect @@ -425,8 +425,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connect_btn.setText("Disconnect" if result else "Connect") if result: self.client_watcher.start_watching() - self._status(await self.tec_client.hw_rev()) - self.fan_update(await self.tec_client.fan()) + self._status(await self.client.hw_rev()) + self.fan_update(await self.client.fan()) else: self.status_lbl.setText("Disconnected") self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) @@ -466,24 +466,24 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(int) async def fan_set(self, value): - if not self.tec_client.is_connected(): + if not self.client.is_connected(): return if self.fan_auto_box.isChecked(): with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(False) - await self.tec_client.set_param("fan", value) + await self.client.set_param("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.tec_client.is_connected(): + if not self.client.is_connected(): return if enabled: - await self.tec_client.set_param("fan", "auto") - self.fan_update(await self.tec_client.fan()) + await self.client.set_param("fan", "auto") + self.fan_update(await self.client.fan()) else: - await self.tec_client.set_param("fan", self.fan_power_slider.value()) + await self.client.set_param("fan", self.fan_power_slider.value()) @asyncSlot(int) async def on_report_box_stateChanged(self, enabled): @@ -491,48 +491,48 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if enabled: self.report_mode_task = asyncio.create_task(self.report_mode()) else: - self.tec_client.stop_report_mode() + self.client.stop_report_mode() await self.report_mode_task self.report_mode_task = None async def report_mode(self): - async for report in self.tec_client.report_mode(): + async for report in self.client.report_mode(): self.client_watcher.report_update.emit(report) async def stop_report_mode(self): if self.report_mode_task is not None: - self.tec_client.stop_report_mode() + self.client.stop_report_mode() await self.report_mode_task self.report_mode_task = None @asyncClose async def closeEvent(self, event): await self._on_connection_changed(False) - await self.tec_client.disconnect() + await self.client.disconnect() @asyncSlot() async def on_connect_btn_clicked(self): ip, port = self.host_set_line.text(), self.port_set_spin.value() try: - if not (self.tec_client.is_connecting() or self.tec_client.is_connected()): + if not (self.client.is_connecting() or self.client.is_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.tec_client.connect(host=ip, port=port, timeout=30) + await self.client.connect(host=ip, port=port, timeout=30) except StoppedConnecting: return await self._on_connection_changed(True) else: await self._on_connection_changed(False) - await self.tec_client.disconnect() + await self.client.disconnect() except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11 logging.error(f"Failed communicating to {ip}:{port}: {e}") await self._on_connection_changed(False) - await self.tec_client.disconnect() + await self.client.disconnect() @pyqtSlot(list) def plot(self, report): @@ -550,7 +550,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for param, change, data in changes: if param.name() == 'Temperature PID' and not data: ch = param.opts["payload"] - await self.tec_client.set_param('pwm', ch, 'i_set', params[ch].child('Constant Current').value()) + await self.client.set_param('pwm', ch, 'i_set', params[ch].child('Constant Current').value()) line = getattr(self, f'ch{ch}_t_line') line.setVisible(False) elif param.opts.get("commands", None) is not None: @@ -558,7 +558,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): getattr(self, f'ch{param.opts["payload"]}_t_line').setVisible(True) elif param.name() == 'Set Temperature': getattr(self, f'ch{param.opts["payload"]}_t_line').setValue(data) - await asyncio.gather(*[self.tec_client._command(x.format(value=data)) for x in param.opts["commands"]]) + await asyncio.gather(*[self.client._command(x.format(value=data)) for x in param.opts["commands"]]) def _set_param_tree(self): for i, tree in enumerate((self.ch0_tree, self.ch1_tree)): From bfec9efbeceb6b29559af150deecede182f786f7 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 1 Aug 2023 10:33:53 +0800 Subject: [PATCH 125/247] Implement DFU mode --- pytec/pytec/aioclient.py | 13 +++++++++++++ pytec/tec_qt.py | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index cf016ce..b17dea7 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -240,3 +240,16 @@ class Client: await self._writer.drain() await self.disconnect() + + async def dfu(self): + """Put the Thermostat in DFU update mode + + The client is disconnected as the Thermostat stops responding to + TCP commands in DFU update mode. The only way to exit it is by + power-cycling. + """ + async with self._command_lock: + self._writer.write("dfu\n".encode('utf-8')) + await self._writer.drain() + + await self.disconnect() diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 5dc718e..417740d 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -320,7 +320,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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) + self.thermostat_menu.addAction(self.actionNetwork_Settings) self.thermostat_menu.addAction(self.actionLoad_all_configs) self.thermostat_menu.addAction(self.actionSave_all_configs) From 967492642ebae278cd5f4d373b1ed205142a9a50 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 1 Aug 2023 10:34:41 +0800 Subject: [PATCH 126/247] Add load and save configs --- pytec/tec_qt.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 417740d..a19eadc 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -331,7 +331,19 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.thermostat_menu.addAction(self.actionEnter_DFU_Mode) 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(): From 169b89208d55ee105dd1324e0840eb7478bea378 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 1 Aug 2023 13:33:38 +0800 Subject: [PATCH 127/247] Use direct calling in report mode --- pytec/tec_qt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a19eadc..60cface 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -518,7 +518,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): async def report_mode(self): async for report in self.client.report_mode(): - self.client_watcher.report_update.emit(report) + self.plot(report) + self.update_report(report) async def stop_report_mode(self): if self.report_mode_task is not None: From 2a31cdb1af657283ee0ca4e40a982f0949db096e Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 1 Aug 2023 13:33:48 +0800 Subject: [PATCH 128/247] Add ipv4 config --- pytec/pytec/aioclient.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index b17dea7..ab4fb6a 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -253,3 +253,7 @@ class Client: await self._writer.drain() await self.disconnect() + + async def ipv4(self): + """Get the IPv4 settings of the Thermostat""" + return await self._command('ipv4') From 39a78b92c473c3c91106379564af99f9c8c7193b Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 1 Aug 2023 16:48:46 +0800 Subject: [PATCH 129/247] Implement IPv4 settings dialog --- pytec/tec_qt.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 60cface..5ea713d 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -330,6 +330,32 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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:
{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) From 7149fb6d85be4183c8620596ff133f9d2b9e827c Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 2 Aug 2023 14:43:50 +0800 Subject: [PATCH 130/247] Shield pending commands from cancellation --- pytec/pytec/aioclient.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index ab4fb6a..aefa07e 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -74,12 +74,16 @@ class Client: chunk = await self._reader.readline() return chunk.decode('utf-8', errors='ignore') + async def _read_write(self, command): + self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8')) + await self._writer.drain() + + return await self._read_line() + async def _command(self, *command): async with self._command_lock: - self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8')) - await self._writer.drain() - - line = await self._read_line() + # protect the read-write process from being cancelled midway + line = await asyncio.shield(self._read_write(command)) response = json.loads(line) logging.debug(f"{command}: {response}") From c1ae69f218915ace047213bfdf636b084a62b729 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 2 Aug 2023 17:32:29 +0800 Subject: [PATCH 131/247] Enable axis linking functionality --- pytec/tec_qt.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 5ea713d..a52b744 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -442,6 +442,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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() From 3c9541fea2fdd8cef152b2bab66005deba16dab8 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 3 Aug 2023 14:42:11 +0800 Subject: [PATCH 132/247] host --- pytec/tec_qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a52b744..bff5198 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -563,7 +563,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot() async def on_connect_btn_clicked(self): - ip, port = self.host_set_line.text(), self.port_set_spin.value() + host, port = self.host_set_line.text(), self.port_set_spin.value() try: if not (self.client.is_connecting() or self.client.is_connected()): self.status_lbl.setText("Connecting...") @@ -572,7 +572,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.port_set_spin.setEnabled(False) try: - await self.client.connect(host=ip, port=port, timeout=30) + await self.client.connect(host=host, port=port, timeout=30) except StoppedConnecting: return await self._on_connection_changed(True) @@ -581,7 +581,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): await self.client.disconnect() except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11 - logging.error(f"Failed communicating to {ip}:{port}: {e}") + logging.error(f"Failed communicating to {host}:{port}: {e}") await self._on_connection_changed(False) await self.client.disconnect() From fde4e42069147b332faf62140603d68b350b3262 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 11:00:33 +0800 Subject: [PATCH 133/247] Set status first in _on_connection_changed --- pytec/tec_qt.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index bff5198..cb034ba 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -474,8 +474,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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() - self._status(await self.client.hw_rev()) self.fan_update(await self.client.fan()) else: self.status_lbl.setText("Disconnected") @@ -485,6 +486,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.report_box.setChecked(False) await self.stop_report_mode() self.client_watcher.stop_watching() + self.status_lbl.setText("Disconnected") def _set_fan_pwm_warning(self): if self.fan_power_slider.value() != 100: @@ -498,7 +500,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _status(self, hw_rev_d: dict): logging.debug(hw_rev_d) - self.hw_rev_data = 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"]) From 98f2d70cf687e85cb3776b21813b9c9b1e13d37c Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 11:51:16 +0800 Subject: [PATCH 134/247] Match statement --- pytec/tec_qt.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index cb034ba..961691f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -606,10 +606,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): line = getattr(self, f'ch{ch}_t_line') line.setVisible(False) elif param.opts.get("commands", None) is not None: - if param.name() == 'Temperature PID': - getattr(self, f'ch{param.opts["payload"]}_t_line').setVisible(True) - elif param.name() == 'Set Temperature': - getattr(self, f'ch{param.opts["payload"]}_t_line').setValue(data) + match param.name(): + case 'Temperature PID': + getattr(self, f'ch{param.opts["payload"]}_t_line').setVisible(True) + case 'Set Temperature': + getattr(self, f'ch{param.opts["payload"]}_t_line').setValue(data) await asyncio.gather(*[self.client._command(x.format(value=data)) for x in param.opts["commands"]]) def _set_param_tree(self): From 7e89bf5337b79b511229e7e8b4d375fff8a3ea05 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 12:32:08 +0800 Subject: [PATCH 135/247] Better send_command --- pytec/tec_qt.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 961691f..24fc195 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -599,19 +599,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(object, object) async def send_command(self, param, changes): - for param, change, data in changes: - if param.name() == 'Temperature PID' and not data: - ch = param.opts["payload"] - await self.client.set_param('pwm', ch, 'i_set', params[ch].child('Constant Current').value()) - line = getattr(self, f'ch{ch}_t_line') - line.setVisible(False) - elif param.opts.get("commands", None) is not None: - match param.name(): + for inner_param, change, data in changes: + if inner_param.opts.get("commands", None) is not None: + match inner_param.name(): case 'Temperature PID': - getattr(self, f'ch{param.opts["payload"]}_t_line').setVisible(True) + pid_enabled = data + ch = inner_param.opts['payload'] + 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{param.opts["payload"]}_t_line').setValue(data) - await asyncio.gather(*[self.client._command(x.format(value=data)) for x in param.opts["commands"]]) + ch = inner_param.opts['payload'] + 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)): From 02619f133893a9e9dfbc77f346b473b658e3d11b Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 12:52:15 +0800 Subject: [PATCH 136/247] Convinience to turn down fan on connect --- pytec/tec_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 24fc195..e6528e0 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -478,6 +478,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._status(self.hw_rev_data) self.client_watcher.start_watching() self.fan_update(await self.client.fan()) + # await self.client.set_param("fan", 1) else: self.status_lbl.setText("Disconnected") self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) From 6f40adb19d5e18706b4685be392c736b93a3ab44 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 12:57:46 +0800 Subject: [PATCH 137/247] Max current plus-or-minus better informs --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e6528e0..8f88fd5 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -32,7 +32,7 @@ THERMOSTAT_PARAMETERS = [[ 'suffix': '°C', 'commands': [f'pid {ch} target {{value}}'], 'payload': ch}, ]}, {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, + {'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, From 82c357660366c161f439caaff6d805158ecd5d67 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 17:46:38 +0800 Subject: [PATCH 138/247] Don't use payload to get channel Use parent param instead --- pytec/tec_qt.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8f88fd5..860636f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -26,10 +26,10 @@ class CommandsParameter(Parameter): 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'], 'payload': ch, + {'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}}'], 'payload': ch}, + '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': '±', @@ -65,8 +65,8 @@ THERMOSTAT_PARAMETERS = [[ params = [ - CommandsParameter.create(name='Thermostat Params 0', type='group', children=THERMOSTAT_PARAMETERS[0]), - CommandsParameter.create(name='Thermostat Params 1', type='group', children=THERMOSTAT_PARAMETERS[1]), + CommandsParameter.create(name='Thermostat Params 0', type='group', value=0, children=THERMOSTAT_PARAMETERS[0]), + CommandsParameter.create(name='Thermostat Params 1', type='group', value=1, children=THERMOSTAT_PARAMETERS[1]), ] @@ -602,10 +602,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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 - ch = inner_param.opts['payload'] 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()) @@ -613,7 +613,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): await self.client.set_param('pwm', ch, 'i_set', param.child('Constant Current').value()) return case 'Set Temperature': - ch = inner_param.opts['payload'] 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"]]) From cc60ceefa9df40d75e4c5786ef377907d1bc440c Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 13:30:44 +0800 Subject: [PATCH 139/247] Unused import --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 860636f..371a0cd 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,6 +1,6 @@ from PyQt6 import QtWidgets, QtGui, QtCore from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot -from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType +from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem import pyqtgraph as pg from pglive.sources.data_connector import DataConnector from pglive.kwargs import Axis From 980812de671afb9b1f2d85624d6d42bef27ac5c1 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 14:26:57 +0800 Subject: [PATCH 140/247] Full name of the parameter tree --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 371a0cd..685e91e 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -65,8 +65,8 @@ THERMOSTAT_PARAMETERS = [[ params = [ - CommandsParameter.create(name='Thermostat Params 0', type='group', value=0, children=THERMOSTAT_PARAMETERS[0]), - CommandsParameter.create(name='Thermostat Params 1', type='group', value=1, children=THERMOSTAT_PARAMETERS[1]), + CommandsParameter.create(name='Thermostat Channel 0 Parameters', type='group', value=0, children=THERMOSTAT_PARAMETERS[0]), + CommandsParameter.create(name='Thermostat Channel 1 Parameters', type='group', value=1, children=THERMOSTAT_PARAMETERS[1]), ] From f3e13cbb0b5df0fca5e4916009e76d8a66398a78 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 4 Aug 2023 14:31:31 +0800 Subject: [PATCH 141/247] List comprehension --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 685e91e..bde1c9b 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -65,8 +65,8 @@ THERMOSTAT_PARAMETERS = [[ params = [ - CommandsParameter.create(name='Thermostat Channel 0 Parameters', type='group', value=0, children=THERMOSTAT_PARAMETERS[0]), - CommandsParameter.create(name='Thermostat Channel 1 Parameters', type='group', value=1, children=THERMOSTAT_PARAMETERS[1]), + CommandsParameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=THERMOSTAT_PARAMETERS[ch]) + for ch in range(2) ] From 953e314abb6e5ceed0f0778764a09282df9441de Mon Sep 17 00:00:00 2001 From: atse Date: Sun, 6 Aug 2023 23:36:54 +0800 Subject: [PATCH 142/247] Add optional channel selection for save/load --- pytec/pytec/aioclient.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index aefa07e..ca674d4 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -218,13 +218,13 @@ class Client: await self.set_param("pid", channel, "target", value=target) await self.set_param("pwm", channel, "pid") - async def save_config(self): + async def save_config(self, channel=""): """Save current configuration to EEPROM""" - await self._command("save") + await self._command("save", str(channel)) - async def load_config(self): + async def load_config(self, channel=""): """Load current configuration from EEPROM""" - await self._command("load") + await self._command("load", str(channel)) async def hw_rev(self): """Get Thermostat hardware revision""" From 3597fb4445b1b4cde74f5f6f94dad2d4103bf642 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 7 Aug 2023 13:06:38 +0800 Subject: [PATCH 143/247] Fan group to be set based on hw_rev only --- pytec/tec_qt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index bde1c9b..91f2798 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -466,7 +466,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): async def _on_connection_changed(self, result): self.graph_group.setEnabled(result) - self.fan_group.setEnabled(result) self.report_group.setEnabled(result) self.thermostat_settings.setEnabled(result) From e82437ca9f7dc917e95f695e3394defd559126e0 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 8 Aug 2023 11:39:39 +0800 Subject: [PATCH 144/247] Move global params into window --- pytec/tec_qt.py | 50 ++++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 91f2798..c02b334 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -64,12 +64,6 @@ THERMOSTAT_PARAMETERS = [[ ] for ch in range(2)] -params = [ - CommandsParameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=THERMOSTAT_PARAMETERS[ch]) - for ch in range(2) -] - - def get_argparser(): parser = argparse.ArgumentParser(description="ARTIQ master") @@ -150,6 +144,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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() @@ -617,54 +615,54 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _set_param_tree(self): for i, tree in enumerate((self.ch0_tree, self.ch1_tree)): - tree.setParameters(params[i], showTop=False) - params[i].sigTreeStateChanged.connect(self.send_command) + 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(params[channel]): - params[channel].child("PID Config", "Kp").setValue(settings["parameters"]["kp"]) - params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"]) - params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"]) - if params[channel].child("Temperature PID").value(): - params[channel].child("Temperature PID", "Set Temperature").setValue(settings["target"]) + 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(params[channel]): - params[channel].child("Temperature PID").setValue(settings["pid_engaged"]) + 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"]: - params[channel].child("Constant Current").setValue(settings["i_set"]) + 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(params[channel]): - params[channel].child("Thermistor Config", "T₀").setValue(sh_param["params"]["t0"] - 273.15) - params[channel].child("Thermistor Config", "R₀").setValue(sh_param["params"]["r0"]) - params[channel].child("Thermistor Config", "β").setValue(sh_param["params"]["b"]) + 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(params[channel]): - params[channel].child("Output Config", "Max Voltage").setValue(pwm_params["max_v"]["value"]) - params[channel].child("Output Config", "Max Current").setValue(pwm_params["max_i_pos"]["value"]) + 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(params[channel]): - params[channel].child("Postfilter Config", "Rate").setValue(postfilter_params["rate"]) + with QSignalBlocker(self.params[channel]): + self.params[channel].child("Postfilter Config", "Rate").setValue(postfilter_params["rate"]) async def coro_main(): From 3e2065810700d8843e4eac9e83b4faeddfd014a3 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 8 Aug 2023 16:23:07 +0800 Subject: [PATCH 145/247] Proper timeout implementation --- pytec/pytec/aioclient.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index ca674d4..3aa9b7b 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -15,6 +15,7 @@ class Client: self._connecting_task = None self._command_lock = asyncio.Lock() self._report_mode_on = False + self.timeout = None async def connect(self, host='192.168.1.26', port=23, timeout=None): """Connect to the Thermostat with host and port. @@ -31,6 +32,7 @@ class Client: self._connecting_task = asyncio.create_task( asyncio.wait_for(asyncio.open_connection(host, port), timeout) ) + self.timeout = timeout try: self._reader, self._writer = await self._connecting_task except asyncio.CancelledError: @@ -71,7 +73,7 @@ class Client: async def _read_line(self): # read 1 line - chunk = await self._reader.readline() + chunk = await asyncio.wait_for(self._reader.readline(), self.timeout) # Only wait for response until timeout return chunk.decode('utf-8', errors='ignore') async def _read_write(self, command): From ae9c34f4115eda3c84c130a57b6879c494ac8810 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 8 Aug 2023 16:24:57 +0800 Subject: [PATCH 146/247] Proper report --- pytec/pytec/aioclient.py | 4 ++++ pytec/tec_qt.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 3aa9b7b..95ca4ad 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -160,6 +160,10 @@ class Client: """ return await self._get_conf("postfilter") + async def report(self): + """Obtain one-time report on measurement values""" + return await self._command("report") + async def report_mode(self): """Start reporting measurement values diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c02b334..9af0a87 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -103,7 +103,7 @@ class ClientWatcher(QObject): self.fan_update.emit(await self.client.fan()) self.pwm_update.emit(await self.client.get_pwm()) if self.poll_for_report: - self.report_update.emit(await self.client._command("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()) From bc4b5bb61518db2342a4915d67cb5238480281c0 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 8 Aug 2023 16:56:36 +0800 Subject: [PATCH 147/247] Bail Disconnects everything, stops all polling --- pytec/tec_qt.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 9af0a87..24abf7a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -557,8 +557,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncClose async def closeEvent(self, event): - await self._on_connection_changed(False) - await self.client.disconnect() + await self.bail() @asyncSlot() async def on_connect_btn_clicked(self): @@ -576,13 +575,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return await self._on_connection_changed(True) else: - await self._on_connection_changed(False) - await self.client.disconnect() + 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._on_connection_changed(False) - await self.client.disconnect() + await self.bail() + + async def bail(self): + await self._on_connection_changed(False) + await self.client.disconnect() @pyqtSlot(list) def plot(self, report): From 05bc5d8809c82298ab8fb1708a2a9f3f1cc804d9 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 9 Aug 2023 11:09:28 +0800 Subject: [PATCH 148/247] Remove is_ prefix --- pytec/pytec/aioclient.py | 4 ++-- pytec/tec_qt.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 95ca4ad..2e5b22b 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -42,11 +42,11 @@ class Client: await self._check_zero_limits() - def is_connecting(self): + def connecting(self): """Returns True if client is connecting""" return self._connecting_task is not None - def is_connected(self): + def connected(self): """Returns True if client is connected""" return self._writer is not None diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 24abf7a..f909cb0 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -515,7 +515,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(int) async def fan_set(self, value): - if not self.client.is_connected(): + if not self.client.connected(): return if self.fan_auto_box.isChecked(): with QSignalBlocker(self.fan_auto_box): @@ -526,7 +526,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(int) async def fan_auto_set(self, enabled): - if not self.client.is_connected(): + if not self.client.connected(): return if enabled: await self.client.set_param("fan", "auto") @@ -563,7 +563,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): async def on_connect_btn_clicked(self): host, port = self.host_set_line.text(), self.port_set_spin.value() try: - if not (self.client.is_connecting() or self.client.is_connected()): + 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) From c6815950d243da5a76463328f1ea30bddaf4064d Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 9 Aug 2023 11:13:43 +0800 Subject: [PATCH 149/247] Use start and end session nomenclature Helps when we also inherit from QObject, which already has connect and disconnect methods. --- pytec/aioexample.py | 2 +- pytec/pytec/aioclient.py | 14 +++++++------- pytec/tec_qt.py | 5 +++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pytec/aioexample.py b/pytec/aioexample.py index 2214764..42c02b4 100644 --- a/pytec/aioexample.py +++ b/pytec/aioexample.py @@ -3,7 +3,7 @@ from pytec.aioclient import Client async def main(): tec = Client() - await tec.connect() #(host="192.168.1.26", port=23) + await tec.start_session() #(host="192.168.1.26", port=23) await tec.set_param("s-h", 1, "t0", 20) print(await tec.get_pwm()) print(await tec.get_pid()) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 2e5b22b..5fa0e78 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -17,15 +17,15 @@ class Client: self._report_mode_on = False self.timeout = None - async def connect(self, host='192.168.1.26', port=23, timeout=None): - """Connect to the Thermostat with host and port. + async def start_session(self, host='192.168.1.26', port=23, timeout=None): + """Start session to Thermostat at specified host and port. Throws StoppedConnecting if disconnect was called while connecting. Throws asyncio.TimeoutError if timeout was exceeded. Example:: client = Client() try: - await client.connect() + await client.start_session() except StoppedConnecting: print("Stopped connecting") """ @@ -50,8 +50,8 @@ class Client: """Returns True if client is connected""" return self._writer is not None - async def disconnect(self): - """Disconnect the client if connected, cancel connection if connecting""" + async def end_session(self): + """End session to Thermostat if connected, cancel connection if connecting""" if self._connecting_task is not None: self._connecting_task.cancel() @@ -249,7 +249,7 @@ class Client: self._writer.write("reset\n".encode('utf-8')) await self._writer.drain() - await self.disconnect() + await self.end_session() async def dfu(self): """Put the Thermostat in DFU update mode @@ -262,7 +262,7 @@ class Client: self._writer.write("dfu\n".encode('utf-8')) await self._writer.drain() - await self.disconnect() + await self.end_session() async def ipv4(self): """Get the IPv4 settings of the Thermostat""" diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index f909cb0..d699265 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -570,7 +570,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.port_set_spin.setEnabled(False) try: - await self.client.connect(host=host, port=port, timeout=30) + await self.client.start_session(host=host, port=port, timeout=30) except StoppedConnecting: return await self._on_connection_changed(True) @@ -581,9 +581,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.disconnect() + await self.client.end_session() @pyqtSlot(list) def plot(self, report): From f6dc882d9b41f24177360e6da774445e855f9e4f Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 9 Aug 2023 11:15:29 +0800 Subject: [PATCH 150/247] Handle timeout errors --- pytec/tec_qt.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index d699265..e8c097f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -77,6 +77,17 @@ def get_argparser(): return parser +class WrappedClient(QObject, Client): + connection_error = pyqtSignal() + + 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() + + class ClientWatcher(QObject): fan_update = pyqtSignal(dict) pwm_update = pyqtSignal(list) @@ -169,7 +180,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.hw_rev_data = None - self.client = Client() + self.client = WrappedClient() + 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) From a54773d3aebe9873d0ad51aebd76973410b71835 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 11 Aug 2023 16:08:50 +0800 Subject: [PATCH 151/247] Add proper set_fan and get_fan coroutine methods --- pytec/pytec/aioclient.py | 12 ++++++++---- pytec/tec_qt.py | 12 ++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 5fa0e78..8bf004b 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -160,6 +160,10 @@ class Client: """ return await self._get_conf("postfilter") + async def get_fan(self): + """Get Thermostat current fan settings""" + return await self._command("fan") + async def report(self): """Obtain one-time report on measurement values""" return await self._command("report") @@ -219,6 +223,10 @@ class Client: value = str(value) await self._command(topic, str(channel), field, value) + async def set_fan(self, power="auto"): + """Set fan power""" + await self._command("fan", str(power)) + async def power_up(self, channel, target): """Start closed-loop mode""" await self.set_param("pid", channel, "target", value=target) @@ -236,10 +244,6 @@ class Client: """Get Thermostat hardware revision""" return await self._command("hwrev") - async def fan(self): - """Get Thermostat current fan settings""" - return await self._command("fan") - async def reset(self): """Reset the Thermostat diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e8c097f..e154924 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -111,7 +111,7 @@ class ClientWatcher(QObject): await asyncio.sleep(self.update_s - (loop.time() - time)) async def update_params(self): - self.fan_update.emit(await self.client.fan()) + 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()) @@ -486,7 +486,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.hw_rev_data = await self.client.hw_rev() self._status(self.hw_rev_data) self.client_watcher.start_watching() - self.fan_update(await self.client.fan()) + self.fan_update(await self.client.get_fan()) # await self.client.set_param("fan", 1) else: self.status_lbl.setText("Disconnected") @@ -532,7 +532,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if self.fan_auto_box.isChecked(): with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(False) - await self.client.set_param("fan", value) + await self.client.set_fan(value) if not self.hw_rev_data["settings"]["fan_pwm_recommended"]: self._set_fan_pwm_warning() @@ -541,10 +541,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if not self.client.connected(): return if enabled: - await self.client.set_param("fan", "auto") - self.fan_update(await self.client.fan()) + await self.client.set_fan("auto") + self.fan_update(await self.client.get_fan()) else: - await self.client.set_param("fan", self.fan_power_slider.value()) + await self.client.set_fan(self.fan_power_slider.value()) @asyncSlot(int) async def on_report_box_stateChanged(self, enabled): From c3fdb105eb9b3fa1b14031baa8b323d63e9771c3 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 11 Aug 2023 16:09:37 +0800 Subject: [PATCH 152/247] Add proper set fan curve coroutine method --- pytec/pytec/aioclient.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 8bf004b..7e5ecd2 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -227,6 +227,10 @@ class Client: """Set fan power""" await self._command("fan", str(power)) + async def set_fcurve(self, a=1.0, b=0.0, c=0.0): + """Set fan curve""" + await self._command("fcurve", str(a), str(b), str(c)) + async def power_up(self, channel, target): """Start closed-loop mode""" await self.set_param("pid", channel, "target", value=target) From 01a3601c3b6e48ef7876cea7e0c8cb55f232b7b9 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 11 Aug 2023 17:09:33 +0800 Subject: [PATCH 153/247] Clear warning --- pytec/tec_qt.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e154924..c8bfbf0 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -80,6 +80,9 @@ def get_argparser(): 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() @@ -87,6 +90,18 @@ class WrappedClient(QObject, Client): 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) @@ -180,7 +195,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.hw_rev_data = None - self.client = WrappedClient() + 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) From 4ca3b14877ad911a7b5d38e6b4d754aec4ce50b5 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 11 Aug 2023 17:22:54 +0800 Subject: [PATCH 154/247] Remove stuff that would update on polling anyway --- pytec/tec_qt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c8bfbf0..4d17c1e 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -501,7 +501,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.hw_rev_data = await self.client.hw_rev() self._status(self.hw_rev_data) self.client_watcher.start_watching() - self.fan_update(await self.client.get_fan()) # await self.client.set_param("fan", 1) else: self.status_lbl.setText("Disconnected") From 2db093618537e371c56923a0d7a9b7ecf2328b41 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 14 Aug 2023 16:34:46 +0800 Subject: [PATCH 155/247] Better tooltip --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 4d17c1e..9d3bc39 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -60,7 +60,7 @@ THERMOSTAT_PARAMETERS = [[ {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, ]}, - {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to flash', 'commands': [f'save {ch}']} + {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset', 'commands': [f'save {ch}']} ] for ch in range(2)] From f189b86e06980ddb941f48833173b2c3b0430070 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 10 Aug 2023 13:28:26 +0800 Subject: [PATCH 156/247] Current Also plot iset --- pytec/tec_qt.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 9d3bc39..591503a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -178,8 +178,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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) @@ -190,8 +192,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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 @@ -456,8 +460,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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: @@ -482,11 +488,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.ch1_t_connector, self.ch1_i_connector: + 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): @@ -617,11 +625,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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: + 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): From d7863e5dbd9846a892c6151bdf0e72f85feb248d Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 16 Aug 2023 12:25:51 +0800 Subject: [PATCH 157/247] Privatise ClientWatcher member variables --- pytec/tec_qt.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 591503a..105d782 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -112,10 +112,10 @@ class ClientWatcher(QObject): postfilter_update = pyqtSignal(list) def __init__(self, parent, client, update_s): - self.update_s = update_s - self.client = client - self.watch_task = None - self.poll_for_report = True + self._update_s = update_s + self._client = client + self._watch_task = None + self._poll_for_report = True super().__init__(parent) async def run(self): @@ -123,35 +123,35 @@ class ClientWatcher(QObject): while True: time = loop.time() await self.update_params() - await asyncio.sleep(self.update_s - (loop.time() - time)) + 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()) + 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()) + self._watch_task = asyncio.create_task(self.run()) def is_watching(self): - return self.watch_task is not None + return self._watch_task is not None @pyqtSlot() def stop_watching(self): - if self.watch_task is not None: - self.watch_task.cancel() - self.watch_task = None + if self._watch_task is not None: + self._watch_task.cancel() + self._watch_task = None def set_report_polling(self, enabled: bool): - self.poll_for_report = enabled + self._poll_for_report = enabled @pyqtSlot(float) def set_update_s(self, update_s): - self.update_s = update_s + self._update_s = update_s class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): From 898a6891cf75f9567566fd8b09159eb100cd45cc Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 16 Aug 2023 12:47:43 +0800 Subject: [PATCH 158/247] Fix redundant code report mode --- pytec/tec_qt.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 105d782..2e2a2b0 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -575,8 +575,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.report_mode_task = asyncio.create_task(self.report_mode()) else: self.client.stop_report_mode() - await self.report_mode_task - self.report_mode_task = None async def report_mode(self): async for report in self.client.report_mode(): From aeecde09afd68d2e935ebe4328f12f320eb2f3e1 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 16 Aug 2023 13:07:26 +0800 Subject: [PATCH 159/247] Move report mode bookkeeping into ClientWatcher --- pytec/tec_qt.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2e2a2b0..7619cc9 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -115,6 +115,7 @@ class ClientWatcher(QObject): 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) @@ -146,8 +147,20 @@ class ClientWatcher(QObject): self._watch_task.cancel() self._watch_task = None - def set_report_polling(self, enabled: bool): - self._poll_for_report = enabled + 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): @@ -213,8 +226,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) - self.report_mode_task = None - if args.connect: if args.IP: self.host_set_line.setText(args.IP) @@ -516,7 +527,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_pwm_warning.setToolTip("") self.clear_graphs() self.report_box.setChecked(False) - await self.stop_report_mode() + await self.client_watcher.set_report_mode(False) self.client_watcher.stop_watching() self.status_lbl.setText("Disconnected") @@ -570,22 +581,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(int) async def on_report_box_stateChanged(self, enabled): - self.client_watcher.set_report_polling(not enabled) - if enabled: - self.report_mode_task = asyncio.create_task(self.report_mode()) - else: - self.client.stop_report_mode() - - async def report_mode(self): - async for report in self.client.report_mode(): - self.plot(report) - self.update_report(report) - - async def stop_report_mode(self): - if self.report_mode_task is not None: - self.client.stop_report_mode() - await self.report_mode_task - self.report_mode_task = None + await self.client_watcher.set_report_mode(enabled) @asyncClose async def closeEvent(self, event): From 68503d19e5eadc82f7022c060094740e8886c878 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 16 Aug 2023 13:12:03 +0800 Subject: [PATCH 160/247] Remove --- pytec/tec_qt.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7619cc9..503192c 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -138,9 +138,6 @@ class ClientWatcher(QObject): def start_watching(self): self._watch_task = asyncio.create_task(self.run()) - def is_watching(self): - return self._watch_task is not None - @pyqtSlot() def stop_watching(self): if self._watch_task is not None: @@ -161,7 +158,6 @@ class ClientWatcher(QObject): 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 From c876c1ec0a0d4d5e2222861b6f6ddb5f6d92f993 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 16 Aug 2023 14:33:16 +0800 Subject: [PATCH 161/247] Don't use dynamic properties --- pytec/tec_qt.py | 11 +++++++++++ pytec/tec_qt.ui | 36 ++++++------------------------------ pytec/ui_tec_qt.py | 8 +------- 3 files changed, 18 insertions(+), 37 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 503192c..b449be1 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -643,6 +643,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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) @@ -692,6 +693,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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() diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 16a5699..b2c76f7 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -90,46 +90,22 @@ 2
- - - true - - + - - - true - - + - - - Channel 1 Temperature - - + - - - Channel 0 Temperature - - + - - - Channel 0 Current - - + - - - Channel 1 Current - - +
diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index a9ac249..3c66472 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'tec_qt.ui' # -# Created by: PyQt6 UI code generator 6.5.0 +# Created by: PyQt6 UI code generator 6.5.2 # # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. @@ -45,11 +45,9 @@ class Ui_MainWindow(object): self.graphs_layout.setSpacing(2) self.graphs_layout.setObjectName("graphs_layout") self.ch1_tree = ParameterTree(parent=self.graph_group) - self.ch1_tree.setHeaderHidden(True) self.ch1_tree.setObjectName("ch1_tree") self.graphs_layout.addWidget(self.ch1_tree, 1, 0, 1, 1) self.ch0_tree = ParameterTree(parent=self.graph_group) - self.ch0_tree.setHeaderHidden(True) self.ch0_tree.setObjectName("ch0_tree") self.graphs_layout.addWidget(self.ch0_tree, 0, 0, 1, 1) self.ch1_t_graph = LivePlotWidget(parent=self.graph_group) @@ -221,10 +219,6 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel")) - self.ch1_t_graph.setTitle(_translate("MainWindow", "Channel 1 Temperature")) - self.ch0_t_graph.setTitle(_translate("MainWindow", "Channel 0 Temperature")) - self.ch0_i_graph.setTitle(_translate("MainWindow", "Channel 0 Current")) - self.ch1_i_graph.setTitle(_translate("MainWindow", "Channel 1 Current")) self.bottom_settings_group.setToolTip(_translate("MainWindow", "Thermostat Settings")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) From 790e744822b4cc6186b75d003f7117221a1a8d81 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 16 Aug 2023 14:42:15 +0800 Subject: [PATCH 162/247] Read extra load --- pytec/pytec/aioclient.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 7e5ecd2..1054afa 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -243,6 +243,8 @@ class Client: async def load_config(self, channel=""): """Load current configuration from EEPROM""" await self._command("load", str(channel)) + if channel == "": + await self._read_line() # Read the extra {} async def hw_rev(self): """Get Thermostat hardware revision""" From 0d1cb074e146ca1336e895eb41bdec4efd026610 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 18 Aug 2023 10:15:18 +0800 Subject: [PATCH 163/247] Remove setObjectName --- pytec/tec_qt.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b449be1..acb57d3 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -245,7 +245,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.setObjectName("host_set_line") self.host_set_line.setText("192.168.1.26") self.host_set_line.setPlaceholderText(_translate("MainWindow", "IP for the Thermostat")) @@ -265,7 +264,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215)) self.port_set_spin.setMaximum(65535) self.port_set_spin.setProperty("value", 23) - self.port_set_spin.setObjectName("port_set_spin") port = QtWidgets.QWidgetAction(self.connection_menu) port.setDefaultWidget(self.port_set_spin) @@ -283,14 +281,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_group = QtWidgets.QWidget() self.fan_group.setEnabled(False) self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) - self.fan_group.setObjectName("fan_group") self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group) self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0) self.horizontalLayout_6.setSpacing(0) - self.horizontalLayout_6.setObjectName("horizontalLayout_6") self.gan_layout = QtWidgets.QHBoxLayout() self.gan_layout.setSpacing(9) - self.gan_layout.setObjectName("gan_layout") self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(0) @@ -300,7 +295,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.fan_lbl.setObjectName("fan_lbl") 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) @@ -314,7 +308,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.setMinimum(1) self.fan_power_slider.setMaximum(100) self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal) - self.fan_power_slider.setObjectName("fan_power_slider") 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) @@ -324,12 +317,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.fan_auto_box.setObjectName("fan_auto_box") 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.fan_pwm_warning.setObjectName("fan_pwm_warning") self.gan_layout.addWidget(self.fan_pwm_warning) self.horizontalLayout_6.addLayout(self.gan_layout) From 00b252a3474e0e4e806d7ec83682c863e53a50d8 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 18 Aug 2023 10:32:48 +0800 Subject: [PATCH 164/247] setValue --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index acb57d3..0bacb13 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -263,7 +263,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.setProperty("value", 23) + self.port_set_spin.setValue(23) port = QtWidgets.QWidgetAction(self.connection_menu) port.setDefaultWidget(self.port_set_spin) From 59d26436f6f1689df370caec2fd14fff0511b96a Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 18 Aug 2023 10:51:34 +0800 Subject: [PATCH 165/247] No text why set text --- pytec/tec_qt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 0bacb13..7f14d6f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -320,7 +320,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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) From c10317bfdbb8381f1b3e88ad33f927d3a496119e Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 18 Aug 2023 10:55:52 +0800 Subject: [PATCH 166/247] Remove extra horizontal layout --- pytec/tec_qt.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7f14d6f..2cca896 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -281,10 +281,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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 = QtWidgets.QHBoxLayout(self.fan_group) 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) @@ -321,7 +318,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group) self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0)) 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) From 180146bc34e66df17c988b68097b88eb4ca4c0b4 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 18 Aug 2023 11:10:03 +0800 Subject: [PATCH 167/247] Remove sizePolicy horizontal/vertical stretch 0 Default anyway --- pytec/tec_qt.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2cca896..584ac05 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -237,8 +237,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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)) @@ -256,8 +254,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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)) @@ -285,8 +281,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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)) @@ -295,8 +289,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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)) @@ -308,8 +300,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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)) From 2db9e8fea39fe8b862b631893cfbdefb16963768 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 18 Aug 2023 11:18:04 +0800 Subject: [PATCH 168/247] Use setRange --- pytec/tec_qt.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 584ac05..c20a649 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -294,8 +294,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.setRange(1, 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) From be340ce094b0962ec0eec27a71a430e22982f477 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 18 Aug 2023 11:20:35 +0800 Subject: [PATCH 169/247] Fan layout misspelled --- pytec/tec_qt.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c20a649..c48fcaf 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -277,8 +277,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_group = QtWidgets.QWidget() self.fan_group.setEnabled(False) self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) - self.gan_layout = QtWidgets.QHBoxLayout(self.fan_group) - self.gan_layout.setSpacing(9) + self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group) + self.fan_layout.setSpacing(9) self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHeightForWidth(self.fan_lbl.sizePolicy().hasHeightForWidth()) @@ -286,7 +286,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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_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.setHeightForWidth(self.fan_power_slider.sizePolicy().hasHeightForWidth()) @@ -296,17 +296,17 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0)) self.fan_power_slider.setRange(1, 100) self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal) - self.gan_layout.addWidget(self.fan_power_slider) + self.fan_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.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_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.gan_layout.addWidget(self.fan_pwm_warning) + self.fan_layout.addWidget(self.fan_pwm_warning) self.fan_power_slider.valueChanged.connect(self.fan_set) self.fan_auto_box.stateChanged.connect(self.fan_auto_set) From f1abab9bd6086adb8d6c7e822fe264eb18e70893 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 18 Aug 2023 12:12:01 +0800 Subject: [PATCH 170/247] Forget about sizePolicy --- pytec/tec_qt.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c48fcaf..4eec907 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -236,9 +236,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connection_menu.setTitle('Connection Settings') self.host_set_line = QtWidgets.QLineEdit() - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - 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) @@ -253,9 +250,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connection_menu.host = host self.port_set_spin = QtWidgets.QSpinBox() - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed) - 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) @@ -280,17 +274,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group) self.fan_layout.setSpacing(9) self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - 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.fan_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.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)) @@ -298,9 +286,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal) self.fan_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.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.fan_layout.addWidget(self.fan_auto_box) From 50aafa493f9b02d6bbd3cfac1fcb978dfa425b50 Mon Sep 17 00:00:00 2001 From: atse Date: Sun, 20 Aug 2023 21:32:32 +0800 Subject: [PATCH 171/247] Put thermostat parameters constant into mainwindow --- pytec/tec_qt.py | 85 ++++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 4eec907..31dc9e3 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -22,48 +22,6 @@ 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") @@ -168,6 +126,47 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """The maximum number of sample points to store.""" DEFAULT_MAX_SAMPLES = 1000 + """Thermostat parameters that are particular to a channel""" + 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 __init__(self, args): super().__init__() @@ -180,7 +179,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._set_up_plot_menu() self.params = [ - CommandsParameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=THERMOSTAT_PARAMETERS[ch]) + CommandsParameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch]) for ch in range(2) ] self._set_param_tree() From db766d87074f3d13b76cbbd191363af7e425cee3 Mon Sep 17 00:00:00 2001 From: atse Date: Sun, 20 Aug 2023 21:33:14 +0800 Subject: [PATCH 172/247] CommandsParameter useless anyway --- pytec/tec_qt.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 31dc9e3..1b17702 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -19,9 +19,6 @@ from qasync import asyncSlot, asyncClose from ui_tec_qt import Ui_MainWindow -class CommandsParameter(Parameter): - pass - def get_argparser(): parser = argparse.ArgumentParser(description="ARTIQ master") @@ -179,7 +176,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._set_up_plot_menu() self.params = [ - CommandsParameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch]) + Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch]) for ch in range(2) ] self._set_param_tree() From ef87225339194e0ce861073c3d01d3dbef87c0b1 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 22 Aug 2023 15:37:51 +0800 Subject: [PATCH 173/247] Switch to using set_param if possible --- pytec/tec_qt.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 1b17702..afabd5a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -126,34 +126,34 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Thermostat parameters that are particular to a channel""" 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}}']}, + 'suffix': 'A', 'param': [('pwm', ch, 'i_set')]}, {'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}}']}, + 'suffix': '°C', 'param': [('pid', ch, 'target')]}, ]}, {'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}}']}, + 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, ]}, {'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}}']}, + 'suffix': '°C', 'param': [('s-h', ch, 't0')]}, {'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}}']}, + 'param': [('s-h', ch, 'r0')]}, + {'name': 'β', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'param': [('s-h', ch, 'b')]}, ]}, {'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}}']}, + 'param': [('postfilter', ch, 'rate')]}, ]}, {'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': 'Kp', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°C/A', 'param': [('pid', ch, 'kp')]}, + {'name': 'Ki', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°C/C', 'param': [('pid', ch, 'ki')]}, + {'name': 'Kd', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°Cs²/C', 'param': [('pid', ch, 'kd')]}, {'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'}, @@ -596,6 +596,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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"]]) + elif inner_param.opts.get("param", None) is not None: + await asyncio.gather(*[self.client.set_param(*x, data) for x in inner_param.opts["param"]]) + def _set_param_tree(self): for i, tree in enumerate((self.ch0_tree, self.ch1_tree)): From 4c839f079b1f6723cb1cbe6f9332106fa095e29a Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 23 Aug 2023 14:53:00 +0800 Subject: [PATCH 174/247] Absolute --- pytec/tec_qt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index afabd5a..867a6c3 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -133,10 +133,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 'suffix': '°C', 'param': [('pid', ch, 'target')]}, ]}, {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, 'prefix': '±', + {'name': 'Max Absolute Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, '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, + {'name': 'Max Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, ]}, {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ @@ -642,8 +642,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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"]) + self.params[channel].child("Output Config", "Max Absolute Voltage").setValue(pwm_params["max_v"]["value"]) + self.params[channel].child("Output Config", "Max Absolute Current").setValue(pwm_params["max_i_pos"]["value"]) @pyqtSlot(list) def update_postfilter(self, postfilter_data): From 7f7f749e84b23094572bea7dd8329b5a555143e5 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 22 Aug 2023 13:22:07 +0800 Subject: [PATCH 175/247] Interface change --- pytec/tec_qt.py | 51 ++++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 867a6c3..d714252 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -125,19 +125,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Thermostat parameters that are particular to a channel""" THERMOSTAT_PARAMETERS = [[ - {'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, - 'suffix': 'A', 'param': [('pwm', ch, 'i_set')]}, - {'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', 'param': [('pid', ch, 'target')]}, - ]}, - {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Absolute Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, - '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 Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, - 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, + {'name': 'Temperature', 'type': 'float', 'siPrefix': True, 'suffix': '°C', 'readonly': True}, + {'name': 'Current through TEC', 'type': 'float', 'siPrefix': True, 'suffix': 'A', 'readonly': True}, + {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ + {'name': 'Control Method', 'type': 'list', 'limits': {'Current Setpoint': False, 'Temperature PID': True}, + 'commands': [f'pwm {ch} pid'], 'children': [ + {'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, + 'suffix': 'A', 'param': [('pwm', ch, 'i_set')]}, + {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, + 'suffix': '°C', 'param': [('pid', ch, 'target')]}, + ]}, + {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'Max Absolute Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, + '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 Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, + 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, + ]} ]}, {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, @@ -585,13 +589,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if inner_param.opts.get("commands", None) is not None: ch = param.value() match inner_param.name(): - case 'Temperature PID': + case 'Control Method': 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()) + await self.client.set_param('pwm', ch, 'i_set', inner_param.child('Constant Current').value()) return case 'Set Temperature': getattr(self, f'ch{ch}_t_line').setValue(data) @@ -614,8 +618,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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"]) + if self.params[channel].child("Output Config", "Control Method").value(): + self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"]) getattr(self, f'ch{channel}_t_line').setValue(settings["target"]) @pyqtSlot(list) @@ -623,10 +627,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for settings in report_data: channel = settings["channel"] with QSignalBlocker(self.params[channel]): - self.params[channel].child("Temperature PID").setValue(settings["pid_engaged"]) + self.params[channel].child("Output Config", "Control Method").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"]) + self.params[channel].child("Output Config", "Control Method", "Constant Current").setValue(settings["i_set"]) + if settings['temperature'] is not None: + self.params[channel].child("Temperature").setValue(settings['temperature']) + if settings['tec_i'] is not None: + self.params[channel].child("Current through TEC").setValue(settings['tec_i']) @pyqtSlot(list) def update_thermistor(self, sh_data): @@ -642,8 +649,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for pwm_params in pwm_data: channel = pwm_params["channel"] with QSignalBlocker(self.params[channel]): - self.params[channel].child("Output Config", "Max Absolute Voltage").setValue(pwm_params["max_v"]["value"]) - self.params[channel].child("Output Config", "Max Absolute Current").setValue(pwm_params["max_i_pos"]["value"]) + self.params[channel].child("Output Config", "Limits", "Max Absolute Voltage").setValue(pwm_params["max_v"]["value"]) + self.params[channel].child("Output Config", "Limits", "Max Absolute Current").setValue(pwm_params["max_i_pos"]["value"]) @pyqtSlot(list) def update_postfilter(self, postfilter_data): From 68124cd92bf16480ec53c1f75a2f3b69e21c458d Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 23 Aug 2023 10:53:51 +0800 Subject: [PATCH 176/247] Only show either one or another, pid or not --- pytec/tec_qt.py | 69 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index d714252..ad09ec4 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,6 +1,7 @@ from PyQt6 import QtWidgets, QtGui, QtCore from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot -from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem +import pyqtgraph.parametertree.parameterTypes as pTypes +from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType import pyqtgraph as pg from pglive.sources.data_connector import DataConnector from pglive.kwargs import Axis @@ -32,6 +33,37 @@ def get_argparser(): return parser +class MutexParameter(pTypes.ListParameter): + """ + Mutually exclusive parameter where only one of its children is visible at a time, list selectable. + + The ordering of the list items determines which children will be visible. + """ + def __init__(self, **opts): + super().__init__(**opts) + + self.sigValueChanged.connect(self._show_chosen_child) + self.sigValueChanged.emit(self, self.opts['value']) + + def _get_param_from_value(self, value): + if isinstance(self.opts['limits'], dict): + values_list = list(self.opts['limits'].values()) + else: + values_list = self.opts['limits'] + + return self.children()[values_list.index(value)] + + @pyqtSlot(object, object) + def _show_chosen_child(self, value): + for param in self.children(): + param.hide() + + self._get_param_from_value(value.value()).show() + + +registerParameterType('mutex', MutexParameter) + + class WrappedClient(QObject, Client): connection_error = pyqtSignal() @@ -128,7 +160,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Temperature', 'type': 'float', 'siPrefix': True, 'suffix': '°C', 'readonly': True}, {'name': 'Current through TEC', 'type': 'float', 'siPrefix': True, 'suffix': 'A', 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ - {'name': 'Control Method', 'type': 'list', 'limits': {'Current Setpoint': False, 'Temperature PID': True}, + {'name': 'Control Method', 'type': 'mutex', 'limits': {'Current Setpoint': False, 'Temperature PID': True}, 'commands': [f'pwm {ch} pid'], 'children': [ {'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'suffix': 'A', 'param': [('pwm', ch, 'i_set')]}, @@ -586,22 +618,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @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 'Control Method': - 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', inner_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"]]) - elif inner_param.opts.get("param", None) is not None: - await asyncio.gather(*[self.client.set_param(*x, data) for x in inner_param.opts["param"]]) + if change == 'value': + if inner_param.opts.get("commands", None) is not None: + ch = param.value() + match inner_param.name(): + case 'Control Method': + 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', inner_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"]]) + elif inner_param.opts.get("param", None) is not None: + await asyncio.gather(*[self.client.set_param(*x, data) for x in inner_param.opts["param"]]) def _set_param_tree(self): From 6bca8a2728282b121204711c4045e3afaac5ef42 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 23 Aug 2023 17:06:31 +0800 Subject: [PATCH 177/247] Improve conditionals --- pytec/tec_qt.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index ad09ec4..4eba71e 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -651,9 +651,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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("Output Config", "Control Method").value(): - self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"]) - getattr(self, f'ch{channel}_t_line').setValue(settings["target"]) + self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"]) + getattr(self, f'ch{channel}_t_line').setValue(settings["target"]) @pyqtSlot(list) def update_report(self, report_data): @@ -663,9 +662,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params[channel].child("Output Config", "Control Method").setValue(settings["pid_engaged"]) getattr(self, f'ch{channel}_t_line').setVisible(settings["pid_engaged"]) self.params[channel].child("Output Config", "Control Method", "Constant Current").setValue(settings["i_set"]) - if settings['temperature'] is not None: + if settings['temperature'] is not None and settings['tec_i'] is not None: self.params[channel].child("Temperature").setValue(settings['temperature']) - if settings['tec_i'] is not None: self.params[channel].child("Current through TEC").setValue(settings['tec_i']) @pyqtSlot(list) From 19ffc160e3eeadc1c568402e07380e984f8a48e3 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 24 Aug 2023 12:33:41 +0800 Subject: [PATCH 178/247] Legend --- pytec/tec_qt.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 4eba71e..1017e72 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -218,11 +218,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._set_param_tree() self.ch0_t_plot = LiveLinePlot() - self.ch0_i_plot = LiveLinePlot() - self.ch0_iset_plot = LiveLinePlot(pen=pg.mkPen('r')) + self.ch0_i_plot = LiveLinePlot(name='Feedback') + self.ch0_iset_plot = LiveLinePlot(name='Setpoint', pen=pg.mkPen('r')) self.ch1_t_plot = LiveLinePlot() - self.ch1_i_plot = LiveLinePlot() - self.ch1_iset_plot = LiveLinePlot(pen=pg.mkPen('r')) + self.ch1_i_plot = LiveLinePlot(name='Feedback') + self.ch1_iset_plot = LiveLinePlot(name='Setpoint', pen=pg.mkPen('r')) self.ch0_t_line = self.ch0_t_graph.getPlotItem().addLine(label='{value} °C') self.ch0_t_line.setVisible(False) @@ -484,6 +484,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): current_axis = LiveAxis('left', text="Current", units="A") current_axis.showLabel() graph.setAxisItems({'left': current_axis}) + graph.addLegend(brush=(50, 50, 200, 150)) self.ch0_t_graph.addItem(self.ch0_t_plot) self.ch0_i_graph.addItem(self.ch0_i_plot) From 68ab3555cfa808c02c80cd219f60ec92a02b5103 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 25 Aug 2023 12:14:06 +0800 Subject: [PATCH 179/247] Depend on temperature only --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 1017e72..b083e10 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -611,7 +611,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): iset = report[channel]['i_set'] time = report[channel]['time'] - if temperature is not None and current is not None and iset is not None: + if temperature 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) From 6f0677bac6f9130a4b1cab8c4f4b94a6e76bff31 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 25 Aug 2023 15:53:37 +0800 Subject: [PATCH 180/247] Call it B not Beta --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b083e10..85cc3cc 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -180,7 +180,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 'suffix': '°C', 'param': [('s-h', ch, 't0')]}, {'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω', 'param': [('s-h', ch, 'r0')]}, - {'name': 'β', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'param': [('s-h', ch, 'b')]}, + {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'param': [('s-h', ch, 'b')]}, ]}, {'name': 'Postfilter Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Rate', 'type': 'float', 'value': 16.67, 'step': 0.01, 'suffix': 'Hz', @@ -674,7 +674,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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"]) + self.params[channel].child("Thermistor Config", "B").setValue(sh_param["params"]["b"]) @pyqtSlot(list) def update_pwm(self, pwm_data): From 6014dce1581505dc745ea0955aa87aa3b49cb35f Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 25 Aug 2023 23:44:01 +0800 Subject: [PATCH 181/247] Try triggering on show --- pytec/tec_qt.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 85cc3cc..2190956 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -58,7 +58,11 @@ class MutexParameter(pTypes.ListParameter): for param in self.children(): param.hide() - self._get_param_from_value(value.value()).show() + child_to_show = self._get_param_from_value(value.value()) + child_to_show.show() + + if child_to_show.opts.get('triggerOnShow', None): + child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value()) registerParameterType('mutex', MutexParameter) @@ -162,7 +166,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': {'Current Setpoint': False, 'Temperature PID': True}, 'commands': [f'pwm {ch} pid'], 'children': [ - {'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, + {'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'triggerOnShow': True, 'suffix': 'A', 'param': [('pwm', ch, 'i_set')]}, {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, 'suffix': '°C', 'param': [('pid', ch, 'target')]}, @@ -629,7 +633,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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', inner_param.child('Constant Current').value()) return case 'Set Temperature': getattr(self, f'ch{ch}_t_line').setValue(data) From 744a472566090895bb34c839354e7010c2f19c8f Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 11:12:04 +0800 Subject: [PATCH 182/247] Refactor a bit to update lines only via poll Model-View-Controller thing, don't edit UI when UI value changed --- pytec/tec_qt.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2190956..e097aa2 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -625,17 +625,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for inner_param, change, data in changes: if change == 'value': if inner_param.opts.get("commands", None) is not None: - ch = param.value() - match inner_param.name(): - case 'Control Method': - 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: - return - case 'Set Temperature': - getattr(self, f'ch{ch}_t_line').setValue(data) + if inner_param.name() == 'Control Method' and not data: + return await asyncio.gather(*[self.client._command(x.format(value=data)) for x in inner_param.opts["commands"]]) elif inner_param.opts.get("param", None) is not None: await asyncio.gather(*[self.client.set_param(*x, data) for x in inner_param.opts["param"]]) From 663c46525d87a32f5f64f4a0ce33b7d84c58777e Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 28 Aug 2023 12:46:51 +0800 Subject: [PATCH 183/247] Refactor into class --- pytec/tec_qt.py | 146 ++++++++++++++++++++++++------------------------ 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e097aa2..333ed01 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -154,6 +154,66 @@ class ClientWatcher(QObject): self._update_s = update_s +class ChannelGraphs: + """The maximum number of sample points to store.""" + DEFAULT_MAX_SAMPLES = 1000 + + def __init__(self, t_widget, i_widget): + self._t_widget = t_widget + self._i_widget = i_widget + + self._t_plot = LiveLinePlot() + self._i_plot = LiveLinePlot(name='Feedback') + self._iset_plot = LiveLinePlot(name='Setpoint', pen=pg.mkPen('r')) + + self.t_line = self._t_widget.getPlotItem().addLine(label='{value} °C') + self.t_line.setVisible(False) + + for graph in t_widget, i_widget: + 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 + + temperature_axis = LiveAxis('left', text="Temperature", units="°C") + temperature_axis.showLabel() + t_widget.setAxisItems({'left': temperature_axis}) + + current_axis = LiveAxis('left', text="Current", units="A") + current_axis.showLabel() + i_widget.setAxisItems({'left': current_axis}) + i_widget.addLegend(brush=(50, 50, 200, 150)) + + t_widget.addItem(self._t_plot) + i_widget.addItem(self._i_plot) + i_widget.addItem(self._iset_plot) + + self.t_connector = DataConnector(self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES) + self.i_connector = DataConnector(self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES) + self.iset_connector = DataConnector(self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES) + + self.max_samples = self.DEFAULT_MAX_SAMPLES + + def plot_append(self, report): + temperature = report['temperature'] + current = report['tec_i'] + iset = report['i_set'] + time = report['time'] + + if temperature is not None: + self.t_connector.cb_append_data_point(temperature, time) + self.i_connector.cb_append_data_point(current, time) + self.iset_connector.cb_append_data_point(iset, time) + + def clear(self): + for connector in self.t_connector, self.i_connector, self.iset_connector: + connector.clear() + + class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """The maximum number of sample points to store.""" @@ -221,26 +281,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ] self._set_param_tree() - self.ch0_t_plot = LiveLinePlot() - self.ch0_i_plot = LiveLinePlot(name='Feedback') - self.ch0_iset_plot = LiveLinePlot(name='Setpoint', pen=pg.mkPen('r')) - self.ch1_t_plot = LiveLinePlot() - self.ch1_i_plot = LiveLinePlot(name='Feedback') - self.ch1_iset_plot = LiveLinePlot(name='Setpoint', 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.channel_graphs = [ + ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph')) + for ch in range(2) + ] self.hw_rev_data = None @@ -248,7 +292,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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) @@ -461,45 +504,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @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}) - graph.addLegend(brush=(50, 50, 200, 150)) - - 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) + for channel_graph in self.channel_graphs: + channel_graph.t_connector.max_points = samples + channel_graph.i_connector.max_points = samples + channel_graph.iset_connector.max_points = samples 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() + for channel_graph in self.channel_graphs: + channel_graph.clear() async def _on_connection_changed(self, result): self.graph_group.setEnabled(result) @@ -607,19 +619,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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: - 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: @@ -647,15 +646,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"]) self.params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"]) self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"]) - getattr(self, f'ch{channel}_t_line').setValue(settings["target"]) + self.channel_graphs[channel].t_line.setValue(settings["target"]) @pyqtSlot(list) def update_report(self, report_data): for settings in report_data: channel = settings["channel"] + self.channel_graphs[channel].plot_append(settings) with QSignalBlocker(self.params[channel]): self.params[channel].child("Output Config", "Control Method").setValue(settings["pid_engaged"]) - getattr(self, f'ch{channel}_t_line').setVisible(settings["pid_engaged"]) + self.channel_graphs[channel].t_line.setVisible(settings["pid_engaged"]) self.params[channel].child("Output Config", "Control Method", "Constant Current").setValue(settings["i_set"]) if settings['temperature'] is not None and settings['tec_i'] is not None: self.params[channel].child("Temperature").setValue(settings['temperature']) From fc4f69aec0b201d3c12a751501b0fdee9e1a12f4 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 12:06:48 +0800 Subject: [PATCH 184/247] Change name --- pytec/tec_qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 333ed01..2f843f5 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -224,9 +224,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Temperature', 'type': 'float', 'siPrefix': True, 'suffix': '°C', 'readonly': True}, {'name': 'Current through TEC', 'type': 'float', 'siPrefix': True, 'suffix': 'A', 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ - {'name': 'Control Method', 'type': 'mutex', 'limits': {'Current Setpoint': False, 'Temperature PID': True}, + {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, 'commands': [f'pwm {ch} pid'], 'children': [ - {'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'triggerOnShow': True, + {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'triggerOnShow': True, 'suffix': 'A', 'param': [('pwm', ch, 'i_set')]}, {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, 'suffix': '°C', 'param': [('pid', ch, 'target')]}, @@ -656,7 +656,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): with QSignalBlocker(self.params[channel]): self.params[channel].child("Output Config", "Control Method").setValue(settings["pid_engaged"]) self.channel_graphs[channel].t_line.setVisible(settings["pid_engaged"]) - self.params[channel].child("Output Config", "Control Method", "Constant Current").setValue(settings["i_set"]) + self.params[channel].child("Output Config", "Control Method", "Set Current").setValue(settings["i_set"]) if settings['temperature'] is not None and settings['tec_i'] is not None: self.params[channel].child("Temperature").setValue(settings['temperature']) self.params[channel].child("Current through TEC").setValue(settings['tec_i']) From 4caaf44f742954d186cb3efd68dd57312d842fe8 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 12:11:55 +0800 Subject: [PATCH 185/247] Both command and param --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2f843f5..6a5f26a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -627,7 +627,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if inner_param.name() == 'Control Method' and not data: return await asyncio.gather(*[self.client._command(x.format(value=data)) for x in inner_param.opts["commands"]]) - elif inner_param.opts.get("param", None) is not None: + if inner_param.opts.get("param", None) is not None: await asyncio.gather(*[self.client.set_param(*x, data) for x in inner_param.opts["param"]]) From 5bb64e577fad4585baaddb1a78a46962c954d412 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 12:24:31 +0800 Subject: [PATCH 186/247] Don't use _command --- pytec/tec_qt.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 6a5f26a..c6bc45f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -261,7 +261,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, ]}, - {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset', 'commands': [f'save {ch}']} + {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'} ] for ch in range(2)] def __init__(self, args): @@ -275,19 +275,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._set_up_thermostat_menu() self._set_up_plot_menu() - self.params = [ - Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch]) - for ch in range(2) - ] - self._set_param_tree() - - self.channel_graphs = [ - ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph')) - for ch in range(2) - ] - - 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()) @@ -301,6 +288,19 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) + self.params = [ + Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch]) + for ch in range(2) + ] + self._set_param_tree() + + self.channel_graphs = [ + ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph')) + for ch in range(2) + ] + + self.hw_rev_data = None + if args.connect: if args.IP: self.host_set_line.setText(args.IP) @@ -637,6 +637,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): tree.setParameters(self.params[i], showTop=False) self.params[i].sigTreeStateChanged.connect(self.send_command) + @asyncSlot() + async def save(_, channel=i): + await self.client.save_config(channel) + + self.params[i].child('Save to flash').sigActivated.connect(save) + @pyqtSlot(list) def update_pid(self, pid_settings): for settings in pid_settings: From fe7bc5b7e5de6ff5358310eeb0d10079357a7e37 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 12:27:19 +0800 Subject: [PATCH 187/247] Load config --- pytec/tec_qt.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index c6bc45f..1347e8c 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -261,7 +261,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, ]}, - {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'} + {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'}, + {'name': 'Load from flash', 'type': 'action', 'tip': 'Load config from flash'} ] for ch in range(2)] def __init__(self, args): @@ -643,6 +644,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params[i].child('Save to flash').sigActivated.connect(save) + @asyncSlot() + async def load(_, channel=i): + await self.client.load_config(channel) + + self.params[i].child('Load from flash').sigActivated.connect(load) + @pyqtSlot(list) def update_pid(self, pid_settings): for settings in pid_settings: From 5c8ab769f38642a501b165657e629dd177deb1d3 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 12:59:39 +0800 Subject: [PATCH 188/247] Set param mroe --- pytec/tec_qt.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 1347e8c..52d16b4 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -233,8 +233,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ]}, {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Max Absolute Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, - '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}}']}, + 'suffix': 'A', 'param': [('pwm', ch, 'max_i_pos'), ('pid', ch, 'output_min', '-'), ('pwm', ch, 'max_i_neg'), ('pid', ch, 'output_max')]}, {'name': 'Max Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, ]} @@ -629,7 +628,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return await asyncio.gather(*[self.client._command(x.format(value=data)) for x in inner_param.opts["commands"]]) if inner_param.opts.get("param", None) is not None: - await asyncio.gather(*[self.client.set_param(*x, data) for x in inner_param.opts["param"]]) + for thermostat_param in inner_param.opts["param"]: + if len(thermostat_param) == 4: # To tack on prefixes to the data + await self.client.set_param(thermostat_param[0], thermostat_param[1], thermostat_param[2], f"{thermostat_param[3]}{data}") + else: + await self.client.set_param(*thermostat_param, data) def _set_param_tree(self): From 1581aa40276092e46386187f18961d56c79d39e3 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 13:11:25 +0800 Subject: [PATCH 189/247] No private to slot --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 52d16b4..46a4e2c 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -42,7 +42,7 @@ class MutexParameter(pTypes.ListParameter): def __init__(self, **opts): super().__init__(**opts) - self.sigValueChanged.connect(self._show_chosen_child) + self.sigValueChanged.connect(self.show_chosen_child) self.sigValueChanged.emit(self, self.opts['value']) def _get_param_from_value(self, value): @@ -54,7 +54,7 @@ class MutexParameter(pTypes.ListParameter): return self.children()[values_list.index(value)] @pyqtSlot(object, object) - def _show_chosen_child(self, value): + def show_chosen_child(self, value): for param in self.children(): param.hide() From 4df651902a519f1e1397cd4fb630eb4da702a93d Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 12:39:32 +0800 Subject: [PATCH 190/247] Postfilter option revamped --- pytec/tec_qt.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 46a4e2c..bee2c18 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -246,8 +246,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'param': [('s-h', ch, 'b')]}, ]}, {'name': 'Postfilter Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Rate', 'type': 'float', 'value': 16.67, 'step': 0.01, 'suffix': 'Hz', - 'param': [('postfilter', ch, 'rate')]}, + {'name': 'Postfilter Rate', 'type': 'list', 'value': ('rate', 16.67), 'param': [('postfilter', ch)], + 'limits': {'Off': ('off',), '16.67 Hz': ('rate', 16.67), '20 Hz': ('rate', 20.0), '21.25 Hz': ('rate', 21.25), '27 Hz': ('rate', 27.0)}}, ]}, {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Kp', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°C/A', 'param': [('pid', ch, 'kp')]}, @@ -630,7 +630,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if inner_param.opts.get("param", None) is not None: for thermostat_param in inner_param.opts["param"]: if len(thermostat_param) == 4: # To tack on prefixes to the data - await self.client.set_param(thermostat_param[0], thermostat_param[1], thermostat_param[2], f"{thermostat_param[3]}{data}") + await self.client.set_param(*thermostat_param[:3], f"{thermostat_param[3]}{data}") + elif inner_param.name() == 'Postfilter Rate': + await self.client.set_param(*thermostat_param, *data) else: await self.client.set_param(*thermostat_param, data) @@ -699,7 +701,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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"]) + if postfilter_params["rate"] == None: + self.params[channel].child("Postfilter Config", "Postfilter Rate").setValue(('off',)) + else: + self.params[channel].child("Postfilter Config", "Postfilter Rate").setValue(('rate', postfilter_params["rate"])) def retranslateUi(self, MainWindow): super().retranslateUi(MainWindow) From a213078c9a1429962c75774054bf29bb0db365b0 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 29 Aug 2023 17:30:35 +0800 Subject: [PATCH 191/247] Shorten channel to ch --- pytec/tec_qt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index bee2c18..5918a65 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -644,14 +644,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params[i].sigTreeStateChanged.connect(self.send_command) @asyncSlot() - async def save(_, channel=i): - await self.client.save_config(channel) + async def save(_, ch=i): + await self.client.save_config(ch) self.params[i].child('Save to flash').sigActivated.connect(save) @asyncSlot() - async def load(_, channel=i): - await self.client.load_config(channel) + async def load(_, ch=i): + await self.client.load_config(ch) self.params[i].child('Load from flash').sigActivated.connect(load) From 72956e19eebacbaa38b5831fcdb3bc10354efcf5 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 30 Aug 2023 10:17:39 +0800 Subject: [PATCH 192/247] No more _command --- pytec/tec_qt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 5918a65..36e19af 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -225,7 +225,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Current through TEC', 'type': 'float', 'siPrefix': True, 'suffix': 'A', 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, - 'commands': [f'pwm {ch} pid'], 'children': [ + 'param': [('pwm', ch, 'pid')], 'children': [ {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'triggerOnShow': True, 'suffix': 'A', 'param': [('pwm', ch, 'i_set')]}, {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, @@ -623,16 +623,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): async def send_command(self, param, changes): for inner_param, change, data in changes: if change == 'value': - if inner_param.opts.get("commands", None) is not None: + if inner_param.opts.get("param", None) is not None: if inner_param.name() == 'Control Method' and not data: return - await asyncio.gather(*[self.client._command(x.format(value=data)) for x in inner_param.opts["commands"]]) - if inner_param.opts.get("param", None) is not None: for thermostat_param in inner_param.opts["param"]: if len(thermostat_param) == 4: # To tack on prefixes to the data await self.client.set_param(*thermostat_param[:3], f"{thermostat_param[3]}{data}") elif inner_param.name() == 'Postfilter Rate': await self.client.set_param(*thermostat_param, *data) + elif inner_param.name() == 'Control Method': + await self.client.set_param(*thermostat_param) else: await self.client.set_param(*thermostat_param, data) From c9e8c4f4a1b2b439058a13a5c019bf6d5cf68b02 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 30 Aug 2023 11:12:54 +0800 Subject: [PATCH 193/247] Don't set_param that much --- pytec/tec_qt.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 36e19af..a885501 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -628,13 +628,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return for thermostat_param in inner_param.opts["param"]: if len(thermostat_param) == 4: # To tack on prefixes to the data - await self.client.set_param(*thermostat_param[:3], f"{thermostat_param[3]}{data}") + set_param_args = (*thermostat_param[:3], f'{thermostat_param[3]}{data}') elif inner_param.name() == 'Postfilter Rate': - await self.client.set_param(*thermostat_param, *data) + set_param_args = (*thermostat_param, *data) elif inner_param.name() == 'Control Method': - await self.client.set_param(*thermostat_param) + set_param_args = thermostat_param else: - await self.client.set_param(*thermostat_param, data) + set_param_args = (*thermostat_param, data) + await self.client.set_param(*set_param_args) def _set_param_tree(self): From 4635e71ebffb91e181e5fbe22eab5ebbbff7e6be Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 30 Aug 2023 15:23:03 +0800 Subject: [PATCH 194/247] Autotune --- pytec/setup.py | 2 +- pytec/tec_qt.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/pytec/setup.py b/pytec/setup.py index c084cdf..210b175 100644 --- a/pytec/setup.py +++ b/pytec/setup.py @@ -14,5 +14,5 @@ setup( "tec_qt = tec_qt:main", ] }, - py_modules=['tec_qt', 'ui_tec_qt'], + py_modules=['tec_qt', 'ui_tec_qt', 'autotune'], ) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a885501..e3b1da8 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -15,6 +15,7 @@ import asyncio from pytec.aioclient import Client, StoppedConnecting import qasync from qasync import asyncSlot, asyncClose +from autotune import PIDAutotune, PIDAutotuneState # pyuic6 -x tec_qt.ui -o ui_tec_qt.py from ui_tec_qt import Ui_MainWindow @@ -257,7 +258,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'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': 'Run', 'type': 'action', 'tip': 'Run', 'children': [ + {'name': 'Autotuning...', 'type': 'progress', 'value': 0, 'readonly': True, 'visible': False}, + ]}, ]}, ]}, {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'}, @@ -299,6 +302,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for ch in range(2) ] + self.autotuners = [ + PIDAutotune(25) + for _ in range(2) + ] + self.hw_rev_data = None if args.connect: @@ -656,6 +664,61 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params[i].child('Load from flash').sigActivated.connect(load) + @asyncSlot() + async def autotune(param, ch=i): + match self.autotuners[ch].state(): + case PIDAutotuneState.STATE_OFF: + self.autotuners[ch].setParam( + param.parent().child('Target Temperature').value(), + param.parent().child('Test Current').value(), + param.parent().child('Temperature Swing').value(), + self.report_refresh_spin.value(), + 3) + self.autotuners[ch].setReady() + param.child('Autotuning...').show(True) + param.setOpts(title="Stop") + self.client_watcher.report_update.connect(self.autotune_tick) + case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN: + self.autotuners[ch].setOff() + param.child('Autotuning...').hide() + param.setOpts(title="Run") + await self.client.set_param('pwm', ch, 'i_set', 0) + self.client_watcher.report_update.disconnect(self.autotune_tick) + param.child('Autotuning...').setValue(0) + + self.params[i].child('PID Config', 'PID Auto Tune', 'Run').sigActivated.connect(autotune) + + @asyncSlot(list) + async def autotune_tick(self, report): + for channel_report in report: + channel = channel_report['channel'] + match self.autotuners[channel].state(): + case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN: + self.autotuners[channel].run(channel_report['temperature'], channel_report['time']) + progress_bar = self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...') + if progress_bar.value() < 99: + progress_bar.setValue(progress_bar.value() + 1) # TODO: Measure the progress better + await self.client.set_param('pwm', channel, 'i_set', self.autotuners[channel].output()) + case PIDAutotuneState.STATE_SUCCEEDED: + kp, ki, kd = self.autotuners[channel].get_tec_pid() + self.autotuners[channel].setOff() + self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run") + self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...').hide() + await self.client.set_param('pid', channel, 'kp', kp) + await self.client.set_param('pid', channel, 'ki', ki) + await self.client.set_param('pid', channel, 'kd', kd) + await self.client.set_param('pwm', channel, 'pid') + await self.client.set_param('pid', channel, 'target', self.params[channel].child("PID Config", "PID Auto Tune", "Target Temperature").value()) + self.client_watcher.report_update.disconnect(self.autotune_tick) + self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...').setValue(0) + case PIDAutotuneState.STATE_FAILED: + self.autotuners[channel].setOff() + self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...').hide() + self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run") + await self.client.set_param('pwm', channel, 'i_set', 0) + self.client_watcher.report_update.disconnect(self.autotune_tick) + self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...').setValue(0) + @pyqtSlot(list) def update_pid(self, pid_settings): for settings in pid_settings: From 37c0332c31728d18b96e01529bd79e1e99e9fe4d Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 31 Aug 2023 13:25:39 +0800 Subject: [PATCH 195/247] Legend names & order --- pytec/tec_qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index e3b1da8..49dae7a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -164,8 +164,8 @@ class ChannelGraphs: self._i_widget = i_widget self._t_plot = LiveLinePlot() - self._i_plot = LiveLinePlot(name='Feedback') - self._iset_plot = LiveLinePlot(name='Setpoint', pen=pg.mkPen('r')) + self._i_plot = LiveLinePlot(name='Measured') + self._iset_plot = LiveLinePlot(name='Set', pen=pg.mkPen('r')) self.t_line = self._t_widget.getPlotItem().addLine(label='{value} °C') self.t_line.setVisible(False) @@ -190,8 +190,8 @@ class ChannelGraphs: i_widget.addLegend(brush=(50, 50, 200, 150)) t_widget.addItem(self._t_plot) - i_widget.addItem(self._i_plot) i_widget.addItem(self._iset_plot) + i_widget.addItem(self._i_plot) self.t_connector = DataConnector(self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES) self.i_connector = DataConnector(self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES) From 2231652cb2330803e275febcbf1f7825e57f9e45 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 31 Aug 2023 16:42:55 +0800 Subject: [PATCH 196/247] Correct about thermostat var --- pytec/tec_qt.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 49dae7a..43f2928 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -460,24 +460,24 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self, _translate("MainWindow","About Thermostat"), f""" -

Sinara 8451 Thermostat v{self.hw_rev_d['rev']['major']}.{self.hw_rev_d['rev']['minor']}

+

Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}


Settings:

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']} + a = {self.hw_rev_data['settings']['fan_k_a']}, + b = {self.hw_rev_data['settings']['fan_k_b']}, + c = {self.hw_rev_data['settings']['fan_k_c']}
Fan PWM range: - {self.hw_rev_d['settings']['min_fan_pwm']} – {self.hw_rev_d['settings']['max_fan_pwm']} + {self.hw_rev_data['settings']['min_fan_pwm']} – {self.hw_rev_data['settings']['max_fan_pwm']}
- Fan PWM frequency: {self.hw_rev_d['settings']['fan_pwm_freq_hz']} Hz + Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz
- Fan available: {self.hw_rev_d['settings']['fan_available']} + Fan available: {self.hw_rev_data['settings']['fan_available']}
- Fan PWM recommended: {self.hw_rev_d['settings']['fan_pwm_recommended']} + Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']} """ ) From 82dff9fc05a4015d69376c25417515295ab0d87e Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Sep 2023 10:39:24 +0800 Subject: [PATCH 197/247] Remove bottom settings group tooltip --- pytec/tec_qt.ui | 3 --- pytec/ui_tec_qt.py | 1 - 2 files changed, 4 deletions(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index b2c76f7..c5790d8 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -130,9 +130,6 @@ 40 - - Thermostat Settings - QFrame::StyledPanel diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 3c66472..ca9f1cc 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -219,7 +219,6 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel")) - self.bottom_settings_group.setToolTip(_translate("MainWindow", "Thermostat Settings")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) self.plot_settings.setToolTip(_translate("MainWindow", "Plot Settings")) From a2c7b0b97a19884aa14f59978089207fbcf02a53 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 1 Sep 2023 15:55:14 +0800 Subject: [PATCH 198/247] Spinner --- pytec/setup.py | 2 +- pytec/tec_qt.py | 40 ++++--- pytec/tec_qt.ui | 16 +++ pytec/ui_tec_qt.py | 8 ++ pytec/waitingspinnerwidget.py | 194 ++++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 pytec/waitingspinnerwidget.py diff --git a/pytec/setup.py b/pytec/setup.py index 210b175..4ac8f58 100644 --- a/pytec/setup.py +++ b/pytec/setup.py @@ -14,5 +14,5 @@ setup( "tec_qt = tec_qt:main", ] }, - py_modules=['tec_qt', 'ui_tec_qt', 'autotune'], + py_modules=['tec_qt', 'ui_tec_qt', 'autotune', 'waitingspinnerwidget'], ) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 43f2928..1a12909 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -258,9 +258,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'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', 'children': [ - {'name': 'Autotuning...', 'type': 'progress', 'value': 0, 'readonly': True, 'visible': False}, - ]}, + {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, ]}, {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'}, @@ -307,6 +305,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for _ in range(2) ] + self.loading_spinner.hide() + self.hw_rev_data = None if args.connect: @@ -675,16 +675,25 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.report_refresh_spin.value(), 3) self.autotuners[ch].setReady() - param.child('Autotuning...').show(True) param.setOpts(title="Stop") self.client_watcher.report_update.connect(self.autotune_tick) + self.loading_spinner.show() + self.loading_spinner.start() + if self.autotuners[1 - ch].state() == PIDAutotuneState.STATE_OFF: + self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=ch)) + else: + self.background_task_lbl.setText("Autotuning channel 0 and 1...") case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN: self.autotuners[ch].setOff() - param.child('Autotuning...').hide() param.setOpts(title="Run") await self.client.set_param('pwm', ch, 'i_set', 0) self.client_watcher.report_update.disconnect(self.autotune_tick) - param.child('Autotuning...').setValue(0) + if self.autotuners[1 - ch].state() == PIDAutotuneState.STATE_OFF: + self.background_task_lbl.setText("Ready.") + self.loading_spinner.stop() + self.loading_spinner.hide() + else: + self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch)) self.params[i].child('PID Config', 'PID Auto Tune', 'Run').sigActivated.connect(autotune) @@ -695,29 +704,34 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): match self.autotuners[channel].state(): case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN: self.autotuners[channel].run(channel_report['temperature'], channel_report['time']) - progress_bar = self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...') - if progress_bar.value() < 99: - progress_bar.setValue(progress_bar.value() + 1) # TODO: Measure the progress better await self.client.set_param('pwm', channel, 'i_set', self.autotuners[channel].output()) case PIDAutotuneState.STATE_SUCCEEDED: kp, ki, kd = self.autotuners[channel].get_tec_pid() self.autotuners[channel].setOff() self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run") - self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...').hide() await self.client.set_param('pid', channel, 'kp', kp) await self.client.set_param('pid', channel, 'ki', ki) await self.client.set_param('pid', channel, 'kd', kd) await self.client.set_param('pwm', channel, 'pid') await self.client.set_param('pid', channel, 'target', self.params[channel].child("PID Config", "PID Auto Tune", "Target Temperature").value()) self.client_watcher.report_update.disconnect(self.autotune_tick) - self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...').setValue(0) + if self.autotuners[1 - channel].state() == PIDAutotuneState.STATE_OFF: + self.background_task_lbl.setText("Ready.") + self.loading_spinner.stop() + self.loading_spinner.hide() + else: + self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch)) case PIDAutotuneState.STATE_FAILED: self.autotuners[channel].setOff() - self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...').hide() self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run") await self.client.set_param('pwm', channel, 'i_set', 0) self.client_watcher.report_update.disconnect(self.autotune_tick) - self.params[channel].child('PID Config', 'PID Auto Tune', 'Run', 'Autotuning...').setValue(0) + if self.autotuners[1 - channel].state() == PIDAutotuneState.STATE_OFF: + self.background_task_lbl.setText("Ready.") + self.loading_spinner.stop() + self.loading_spinner.hide() + else: + self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch)) @pyqtSlot(list) def update_pid(self, pid_settings): diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index c5790d8..fb95c2f 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -248,6 +248,16 @@
+ + + + Ready. + + + + + + @@ -511,6 +521,12 @@
pglive.sources.live_plot_widget
1 + + QtWaitingSpinner + QWidget +
waitingspinnerwidget
+ 1 +
diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index ca9f1cc..e879fbc 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -124,6 +124,12 @@ class Ui_MainWindow(object): self.plot_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.plot_settings.setObjectName("plot_settings") self.settings_layout.addWidget(self.plot_settings) + self.background_task_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group) + self.background_task_lbl.setObjectName("background_task_lbl") + self.settings_layout.addWidget(self.background_task_lbl) + self.loading_spinner = QtWaitingSpinner(parent=self.bottom_settings_group) + self.loading_spinner.setObjectName("loading_spinner") + self.settings_layout.addWidget(self.loading_spinner) spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) self.settings_layout.addItem(spacerItem) self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group) @@ -223,6 +229,7 @@ class Ui_MainWindow(object): self.status_lbl.setText(_translate("MainWindow", "Disconnected")) self.plot_settings.setToolTip(_translate("MainWindow", "Plot Settings")) self.plot_settings.setText(_translate("MainWindow", "📉")) + self.background_task_lbl.setText(_translate("MainWindow", "Ready.")) self.report_lbl.setText(_translate("MainWindow", "Poll every: ")) self.report_refresh_spin.setSuffix(_translate("MainWindow", " s")) self.report_box.setText(_translate("MainWindow", "Report")) @@ -241,6 +248,7 @@ class Ui_MainWindow(object): self.actionSave_all_configs.setToolTip(_translate("MainWindow", "Save configuration for all channels to flash")) from pglive.sources.live_plot_widget import LivePlotWidget from pyqtgraph.parametertree import ParameterTree +from waitingspinnerwidget import QtWaitingSpinner if __name__ == "__main__": diff --git a/pytec/waitingspinnerwidget.py b/pytec/waitingspinnerwidget.py new file mode 100644 index 0000000..2c6e647 --- /dev/null +++ b/pytec/waitingspinnerwidget.py @@ -0,0 +1,194 @@ +""" +The MIT License (MIT) + +Copyright (c) 2012-2014 Alexander Turkin +Copyright (c) 2014 William Hallatt +Copyright (c) 2015 Jacob Dawid +Copyright (c) 2016 Luca Weiss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import math + +from PyQt6.QtCore import * +from PyQt6.QtGui import * +from PyQt6.QtWidgets import * + + +class QtWaitingSpinner(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # WAS IN initialize() + self._color = QColor(Qt.GlobalColor.black) + self._roundness = 100.0 + self._minimumTrailOpacity = 3.14159265358979323846 + self._trailFadePercentage = 80.0 + self._revolutionsPerSecond = 1.57079632679489661923 + self._numberOfLines = 20 + self._lineLength = 5 + self._lineWidth = 2 + self._innerRadius = 5 + self._currentCounter = 0 + + self._timer = QTimer(self) + self._timer.timeout.connect(self.rotate) + self.updateSize() + self.updateTimer() + # END initialize() + + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + painter = QPainter(self) + painter.fillRect(self.rect(), Qt.GlobalColor.transparent) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + + painter.setPen(Qt.PenStyle.NoPen) + for i in range(0, self._numberOfLines): + painter.save() + painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength) + rotateAngle = float(360 * i) / float(self._numberOfLines) + painter.rotate(rotateAngle) + painter.translate(self._innerRadius, 0) + distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines) + color = self.currentLineColor(distance, self._numberOfLines, self._trailFadePercentage, + self._minimumTrailOpacity, self._color) + painter.setBrush(color) + painter.drawRoundedRect(QRect(0, int(-self._lineWidth / 2), self._lineLength, self._lineWidth), self._roundness, + self._roundness, Qt.SizeMode.RelativeSize) + painter.restore() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + self._currentCounter = 0 + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + self._currentCounter = 0 + + def setNumberOfLines(self, lines): + self._numberOfLines = lines + self._currentCounter = 0 + self.updateTimer() + + def setLineLength(self, length): + self._lineLength = length + self.updateSize() + + def setLineWidth(self, width): + self._lineWidth = width + self.updateSize() + + def setInnerRadius(self, radius): + self._innerRadius = radius + self.updateSize() + + def color(self): + return self._color + + def roundness(self): + return self._roundness + + def minimumTrailOpacity(self): + return self._minimumTrailOpacity + + def trailFadePercentage(self): + return self._trailFadePercentage + + def revolutionsPersSecond(self): + return self._revolutionsPerSecond + + def numberOfLines(self): + return self._numberOfLines + + def lineLength(self): + return self._lineLength + + def lineWidth(self): + return self._lineWidth + + def innerRadius(self): + return self._innerRadius + + def setRoundness(self, roundness): + self._roundness = max(0.0, min(100.0, roundness)) + + def setColor(self, color=Qt.GlobalColor.black): + self._color = QColor(color) + + def setRevolutionsPerSecond(self, revolutionsPerSecond): + self._revolutionsPerSecond = revolutionsPerSecond + self.updateTimer() + + def setTrailFadePercentage(self, trail): + self._trailFadePercentage = trail + + def setMinimumTrailOpacity(self, minimumTrailOpacity): + self._minimumTrailOpacity = minimumTrailOpacity + + def rotate(self): + self._currentCounter += 1 + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + self.update() + + def updateSize(self): + self.size = (self._innerRadius + self._lineLength) * 2 + self.setFixedSize(self.size, self.size) + + def updateTimer(self): + self._timer.setInterval(int(1000 / (self._numberOfLines * self._revolutionsPerSecond))) + + def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines): + distance = primary - current + if distance < 0: + distance += totalNrOfLines + return distance + + def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput): + color = QColor(colorinput) + if countDistance == 0: + return color + minAlphaF = minOpacity / 100.0 + distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)) + if countDistance > distanceThreshold: + color.setAlphaF(minAlphaF) + else: + alphaDiff = color.alphaF() - minAlphaF + gradient = alphaDiff / float(distanceThreshold + 1) + resultAlpha = color.alphaF() - gradient * countDistance + # If alpha is out of bounds, clip it. + resultAlpha = min(1.0, max(0.0, resultAlpha)) + color.setAlphaF(resultAlpha) + return color + + +if __name__ == '__main__': + app = QApplication([]) + waiting_spinner = QtWaitingSpinner() + waiting_spinner.show() + waiting_spinner.start() + app.exec() From 24006d17bf020cce869ff84bd94815ab2d1496c3 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 12 Sep 2023 17:37:20 +0800 Subject: [PATCH 199/247] Only log the autotuned params --- pytec/autotune.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pytec/autotune.py b/pytec/autotune.py index bf12432..c7d4dda 100644 --- a/pytec/autotune.py +++ b/pytec/autotune.py @@ -222,20 +222,20 @@ class PIDAutotune: # calculate ultimate gain self._Ku = 4.0 * self._outputstep / \ (self._induced_amplitude * math.pi) - print('Ku: {0}'.format(self._Ku)) + logging.debug('Ku: {0}'.format(self._Ku)) # calculate ultimate period in seconds period1 = self._peak_timestamps[3] - self._peak_timestamps[1] period2 = self._peak_timestamps[4] - self._peak_timestamps[2] self._Pu = 0.5 * (period1 + period2) / 1000.0 - print('Pu: {0}'.format(self._Pu)) + logging.debug('Pu: {0}'.format(self._Pu)) for rule in self._tuning_rules: params = self.get_pid_parameters(rule) - print('rule: {0}'.format(rule)) - print('Kp: {0}'.format(params.Kp)) - print('Ki: {0}'.format(params.Ki)) - print('Kd: {0}'.format(params.Kd)) + logging.debug('rule: {0}'.format(rule)) + logging.debug('Kp: {0}'.format(params.Kp)) + logging.debug('Ki: {0}'.format(params.Ki)) + logging.debug('Kd: {0}'.format(params.Kd)) return True return False From 1ff5a5d2b54ac301ebb28840d9f2d7e2a677877e Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 11 Sep 2023 10:45:51 +0800 Subject: [PATCH 200/247] More decimals for temp --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 1a12909..9ab7b05 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -222,7 +222,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Thermostat parameters that are particular to a channel""" THERMOSTAT_PARAMETERS = [[ - {'name': 'Temperature', 'type': 'float', 'siPrefix': True, 'suffix': '°C', 'readonly': True}, + {'name': 'Temperature', 'type': 'float', 'siPrefix': True, 'suffix': '°C', 'decimals': 6, 'readonly': True}, {'name': 'Current through TEC', 'type': 'float', 'siPrefix': True, 'suffix': 'A', 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, From 933e1726c172ac43351e19c337d8ac8c2d1693e3 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 11 Sep 2023 12:10:05 +0800 Subject: [PATCH 201/247] Correct units and stuff --- pytec/tec_qt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 9ab7b05..5433252 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -244,16 +244,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 'suffix': '°C', 'param': [('s-h', ch, 't0')]}, {'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω', 'param': [('s-h', ch, 'r0')]}, - {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'param': [('s-h', ch, 'b')]}, + {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': [('s-h', ch, 'b')]}, ]}, {'name': 'Postfilter Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Postfilter Rate', 'type': 'list', 'value': ('rate', 16.67), 'param': [('postfilter', ch)], 'limits': {'Off': ('off',), '16.67 Hz': ('rate', 16.67), '20 Hz': ('rate', 20.0), '21.25 Hz': ('rate', 21.25), '27 Hz': ('rate', 27.0)}}, ]}, {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Kp', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°C/A', 'param': [('pid', ch, 'kp')]}, - {'name': 'Ki', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°C/C', 'param': [('pid', ch, 'ki')]}, - {'name': 'Kd', 'type': 'float', 'value': 0, 'step': 0.1, 'suffix': '°Cs²/C', 'param': [('pid', ch, 'kd')]}, + {'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': [('pid', ch, 'kp')]}, + {'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': [('pid', ch, 'ki')]}, + {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': [('pid', ch, 'kd')]}, {'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'}, From 9e96be30e93ac46f92881eb2e7f50be1db29826e Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 11 Sep 2023 12:24:45 +0800 Subject: [PATCH 202/247] Get rid of all translation things for now --- pytec/tec_qt.py | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 5433252..2f0f7bc 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -270,6 +270,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.setupUi(self) + self.ch0_t_graph.setTitle("Channel 0 Temperature") + self.ch0_i_graph.setTitle("Channel 0 Current") + self.ch1_t_graph.setTitle("Channel 1 Temperature") + self.ch1_i_graph.setTitle("Channel 1 Current") + self.max_samples = self.DEFAULT_MAX_SAMPLES self._set_up_connection_menu() @@ -317,8 +322,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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') @@ -329,7 +332,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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")) + self.host_set_line.setPlaceholderText("IP for the Thermostat") host = QtWidgets.QWidgetAction(self.connection_menu) host.setDefaultWidget(self.host_set_line) @@ -350,8 +353,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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') @@ -383,9 +384,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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")) + self.fan_lbl.setToolTip("Adjust the fan") + self.fan_lbl.setText("Fan:") + self.fan_auto_box.setText("Auto") fan = QtWidgets.QWidgetAction(self.thermostat_menu) fan.setDefaultWidget(self.fan_group) @@ -416,14 +417,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @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.setWindowTitle("Network Settings") + ask_network.setLabelText("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.setWindowTitle("Set network?") sure.setText(f"Setting this as network and disconnecting:
{ipv4_settings}") @asyncSlot(object) @@ -458,7 +459,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def about_thermostat(): QtWidgets.QMessageBox.about( self, - _translate("MainWindow","About Thermostat"), + "About Thermostat", f"""

Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}

@@ -487,8 +488,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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") @@ -784,16 +783,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: self.params[channel].child("Postfilter Config", "Postfilter Rate").setValue(('rate', 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() From 7ea9752b0f769926de90258c3bfa2b3c129fca30 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 14 Sep 2023 16:53:31 +0800 Subject: [PATCH 203/247] Add window icon --- pytec/tec_qt.ui | 4 ++++ pytec/thermostat-icon-256x256.png | Bin 0 -> 51813 bytes pytec/ui_tec_qt.py | 3 +++ 3 files changed, 7 insertions(+) create mode 100644 pytec/thermostat-icon-256x256.png diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index fb95c2f..d618808 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -25,6 +25,10 @@ Thermostat Control Panel + + + thermostat-icon-256x256.pngthermostat-icon-256x256.png + diff --git a/pytec/thermostat-icon-256x256.png b/pytec/thermostat-icon-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..7e519957190d50d1da6524ddd1ad0641dc009a0b GIT binary patch literal 51813 zcmd2?1yh@Cv#Q>i)pO^B7axQeW} zIMl((-rUN@3;sa^JjT*ITYj<@Vl9Zu~{bKRUV!7vjER&e~Wiqi+e!3j@b`l1;7t; z;tUP7fgP>T31%;j0N5r-Ze(Upf&**(pDPZ9;n}9Z@Z)k`p&?^B<~dpqS}_UXKa_tX z#YN7m^s%+I-@$qK$`BXPP3g*EwsuE0-s8OtRqCt^`dB=SBDU0)^c1WVk4&E8(ziR0 z&TP!tzj(@L`M#_rwZqgVG*g?DoCz3NKBG|q2TBWrm{<8LEk#Q@$c+nH79lP(I5`O8{Q~yXCi*B*CwsImqZZ0{|HK z|J@M)nb{EdMN}7AMM=~>G;9zj%j3rL1pojA$V!N6cq|^bTE&|#xRT$jJl>29jST5} zuX%f!gD}klGCIYKI*Ww1%nHmh-j!^K z)-~c|`A1c~A}RpE4O`uj{mQb_#>L{ARl{3h|9=;mbCwOb_a>0A!25gaKkjD+u)enD zKlOKn??+scamH6H(e0|n2I zca5yfe||Sh0vL{$-mlmZBCIsa$zIAJ;L(*@6z zbg5szFaGQTjfED4`)N*NjEeAIK=7602UM z0SQl8-~cu48Nho??NsD)jjoF+yhfWf^#0mPdmVjSoyN=S2cPFDZ*l>b$1mdoxBYo{ zxpAM(95X&z7aU8DUGZmR1^tNng6ih_ZruJ<81gmr1d(u*z0V{MM@b-4PsN1CK3aLh|t91 z0GMF=q5mGD7sa4(U%`iHtPk zl2}MRTpNqn%R{88glTs#zTQn{8SIOd?l*zKsF_-GmCviv!WWda_G_ngA@TVjK_=`$PnY0Dw&R32TMPK`O-I+ee%m9&EcmE`9oA$(~-s zAA4g+e)&K9;@MCbYKx8kg7>;3%j-_~%Cu5fQ|FnpeCcYInVDY?}R3*?FA zK=#6wdq@;;q0+OtJL5JJunw<#|IaWVsfYKDA;Zg;Bfr;X;Z z6Wt28+qOsSEBgxjr@yy8chshyW(UJ*m3plW?&Efi&i&k~z<{pRk2e>?UxhE1>&9Ou zS6bXB&aOG$#(;Y{fO}xQfZ$+fY*2vnJ+B33v}8C{7(*mD1fh#T0xS{U1r~)+X|C-F zq<(G8uT3o>C{{F9c_)~G72G8=gevaG-bpMo#VS(`6x$wTf3;@YRW;C~R7-JyRb zJ;ot0Ox!-)C#VQ%U%1J&AbB7M3Zi(Rmjp7vlPU`(<#&)(Cyb%gsv{o-Q%4jw}3$Pn5~9hx^Bdet*NP1?YxH_E%iczBjDh#S3Vp2yJ)KB z5$9C+;bEh&U+Af`-_hvx7vcW`QPyHg`JAxx_Fb7FpV#r-BlqbtP2mPum7s)0_!~%= z7)F0&hmAlrQ&W!Etx+vg3mofg0&HPAl2XT-NYwOzyI>j=NcS9(xc!vcEKp!D9EB6G zr6Ulgxr{RD6s$W`@<_r8NRvds=)pt%0Ale3B^RQ^amgvgbP%?BHcBWn%|fTCN<48C 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 literal 0 HcmV?d00001 diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index e879fbc..aec7571 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -15,6 +15,9 @@ class Ui_MainWindow(object): MainWindow.resize(1280, 720) MainWindow.setMinimumSize(QtCore.QSize(1280, 720)) MainWindow.setMaximumSize(QtCore.QSize(3840, 2160)) + icon = QtGui.QIcon() + icon.addPixmap(QtGui.QPixmap("thermostat-icon-256x256.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) + MainWindow.setWindowIcon(icon) self.main_widget = QtWidgets.QWidget(parent=MainWindow) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(1) From 333a759d8524bee7d9eb2f962d429b52dc63ddeb Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 14 Sep 2023 17:01:50 +0800 Subject: [PATCH 204/247] Increase icon resolution --- pytec/tec_qt.ui | 2 +- pytec/thermostat-icon-256x256.png | Bin 51813 -> 0 bytes pytec/thermostat-icon-640x640.png | Bin 0 -> 250092 bytes pytec/ui_tec_qt.py | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 pytec/thermostat-icon-256x256.png create mode 100644 pytec/thermostat-icon-640x640.png diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index d618808..87bb516 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -27,7 +27,7 @@ - thermostat-icon-256x256.pngthermostat-icon-256x256.png + thermostat-icon-640x640.pngthermostat-icon-640x640.png diff --git a/pytec/thermostat-icon-256x256.png b/pytec/thermostat-icon-256x256.png deleted file mode 100644 index 7e519957190d50d1da6524ddd1ad0641dc009a0b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51813 zcmd2?1yh@Cv#Q>i)pO^B7axQeW} zIMl((-rUN@3;sa^JjT*ITYj<@Vl9Zu~{bKRUV!7vjER&e~Wiqi+e!3j@b`l1;7t; z;tUP7fgP>T31%;j0N5r-Ze(Upf&**(pDPZ9;n}9Z@Z)k`p&?^B<~dpqS}_UXKa_tX z#YN7m^s%+I-@$qK$`BXPP3g*EwsuE0-s8OtRqCt^`dB=SBDU0)^c1WVk4&E8(ziR0 z&TP!tzj(@L`M#_rwZqgVG*g?DoCz3NKBG|q2TBWrm{<8LEk#Q@$c+nH79lP(I5`O8{Q~yXCi*B*CwsImqZZ0{|HK z|J@M)nb{EdMN}7AMM=~>G;9zj%j3rL1pojA$V!N6cq|^bTE&|#xRT$jJl>29jST5} zuX%f!gD}klGCIYKI*Ww1%nHmh-j!^K z)-~c|`A1c~A}RpE4O`uj{mQb_#>L{ARl{3h|9=;mbCwOb_a>0A!25gaKkjD+u)enD zKlOKn??+scamH6H(e0|n2I zca5yfe||Sh0vL{$-mlmZBCIsa$zIAJ;L(*@6z zbg5szFaGQTjfED4`)N*NjEeAIK=7602UM z0SQl8-~cu48Nho??NsD)jjoF+yhfWf^#0mPdmVjSoyN=S2cPFDZ*l>b$1mdoxBYo{ zxpAM(95X&z7aU8DUGZmR1^tNng6ih_ZruJ<81gmr1d(u*z0V{MM@b-4PsN1CK3aLh|t91 z0GMF=q5mGD7sa4(U%`iHtPk zl2}MRTpNqn%R{88glTs#zTQn{8SIOd?l*zKsF_-GmCviv!WWda_G_ngA@TVjK_=`$PnY0Dw&R32TMPK`O-I+ee%m9&EcmE`9oA$(~-s zAA4g+e)&K9;@MCbYKx8kg7>;3%j-_~%Cu5fQ|FnpeCcYInVDY?}R3*?FA zK=#6wdq@;;q0+OtJL5JJunw<#|IaWVsfYKDA;Zg;Bfr;X;Z z6Wt28+qOsSEBgxjr@yy8chshyW(UJ*m3plW?&Efi&i&k~z<{pRk2e>?UxhE1>&9Ou zS6bXB&aOG$#(;Y{fO}xQfZ$+fY*2vnJ+B33v}8C{7(*mD1fh#T0xS{U1r~)+X|C-F zq<(G8uT3o>C{{F9c_)~G72G8=gevaG-bpMo#VS(`6x$wTf3;@YRW;C~R7-JyRb zJ;ot0Ox!-)C#VQ%U%1J&AbB7M3Zi(Rmjp7vlPU`(<#&)(Cyb%gsv{o-Q%4jw}3$Pn5~9hx^Bdet*NP1?YxH_E%iczBjDh#S3Vp2yJ)KB z5$9C+;bEh&U+Af`-_hvx7vcW`QPyHg`JAxx_Fb7FpV#r-BlqbtP2mPum7s)0_!~%= z7)F0&hmAlrQ&W!Etx+vg3mofg0&HPAl2XT-NYwOzyI>j=NcS9(xc!vcEKp!D9EB6G zr6Ulgxr{RD6s$W`@<_r8NRvds=)pt%0Ale3B^RQ^amgvgbP%?BHcBWn%|fTCN<48C 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 diff --git a/pytec/thermostat-icon-640x640.png b/pytec/thermostat-icon-640x640.png new file mode 100644 index 0000000000000000000000000000000000000000..12037a6e23633faf9dbc9d1349f2322a3771c64b GIT binary patch literal 250092 zcmeFYWN^y6$;?P2ZTW}8+B*2q< z@AvaBJgk>#SNMR0^dk754<gR>9%4kPtZ?>F`VJ z@Cw%C=AS%_Mb31}DpxR?_^daSt9NCAgsi~(hJQRGrI=A-1T5+A-xYVxapo435GZKP z+sjNml3~6yNHsXCw!G1)0kC-ciAjn%SKuIYFwsaa6F%IwQQyliZzU$E0bqEALtW|4 z!=6bA#dXo2cON6;Q_~f7b^(p)fZCaZ%z4UcINWk}S%~!qi5a6cHQ!==S%{wbm105v z_Q8eVIr=F5cgeR3)OQJJAM<5BA2))|I}F*SCFfPKheJ-uM()Tld_6ecO3t80oWADi6kBaMrLI!qJTSVdV3f^Rv7y6=i@t5SnXX7!xn-vFl^+kaF z;?R9qrK6=KEKH`Iz{_F(_AF0Zh=4Zy#FS# zXC5qj!6!?|h~mB5XZd+J(qa5!HJMY(BBk(iY~g2>W~O?jv0{a~#pBao_gvOzO&7J? z)@K1XcbSkl#t%mr0_`1#9YJM>@SbObqaGw#Le&5F{(lwt|E&U>1Chz;GK8p`1ARp! zdY$?lwFXtSMqG6!hCpkerO-&_-;Bw6yCz)m5+!dB3?6kGNOLK!yaGunlolQf%vEfdusL1Snf2A<>@_)Y_cnNA*-%U5U z$L_wk);kAvoNZiONOI70M-5pZchu|GfHglO^N{e8AXnlcVU)mY0=^(QTForU*yGK6 z)EZJdSte9f8E52(HhaQ=bvTh76A;-CNBunFeXB zZ=e|il*(D#-hIw}(Ov_2FUp);OR07AGbc;uJ#?qD)xp`5fNUDtE%-JjGjo)Pl+^Hv zVG9rlG@N4p-yf~msB-QZ=(+9)3YLDl&)e*JfVl+t&>juF0Ey`@hyB>h#_3P&oj7QR zu2WK0l22r3%jlw(ki;L3iO=DF#V;l|1{d(NiA0)kcp78OPx53<&NwB0+%F8aH=0T=3~+{ZU}e8zRU-W3qJ! zMbYmFbqnwIGP_}zc~-W*+Rmr=f%$O;dxH+W38XpJ5nf4w9c+cCUWSHO5^6JtZD3!^ zK%TL=O%$Hbiw=0wHO8>SZTE?C5*oPy(Rg|}TLeA7!dJ(4adC}CY$X6KE#&wdsf=0C z6`vTDC^(c3Q*P4vdFt%a`m{S(c>M{#nx+fKpn3yjFS54*=o7^md6j@vi&A&N!2R7T zYOa>%0S;D~E z+o|QVb7rYK-0rJCw3~e$&V7>p1mFs$42ppDs8N#_NnY^IeM`dG{pP}>^gyU^xKU>= zNGvTnrFNW)%sKJ;++z|!K^!piH4Q+&TJccOB#+EgF(P=p`Hm1v#FOP?-rCjiHD~!j zq_Zngd;~*8TC@+kNTH5T0Ce{w=xA{o6ICHpK*A2C^B3{CtU5a%3Ec5IFI$JBuLbNa z>r43(fD9^gBAQ-p`?7#)K`%7TN*OmU)_UO2b;nt8;#M@qQh~u$Q-j?41LALchf(`Ts{+<6)(tlkwYXAT^%^q!t_B{-jK-S(z`n@&gs zdU}S26$Pw_il<*@{I_Fn%`VqE67R?Y4=it&XrC$8{9v3$#*+QMb?-E!rl$>X_Kb+V z?(&SzcPu!cdw5G*{Yf0!co+*AH@0A1blZose82Sqaas~d{NeGGD>4`noFzP0XSSxwo%-@Z}}{@!5L+ zXx;w~aX&_IR2+{R%z91Aa=OL?u@>7UsK4f$ zLmEnkX|>1Vh0t&r?gt51^ng#~2hi=Eo!h2A5em26YygGl7W{OykKnrz;<6-b>Ue0V zi%^!iR7c3-l#tVEdqm1W0Du;-wY7z7J)$TgCe}OqMC5dGHs+)8-?G6s&bgl_YtC0Y zrXgJq>qA@j^xm@rpW-~$!!~-=OMa(+hv>z1GMq9QF^dJaosV0pG>bHMk*;Kbw{sa(+&TfdKNBj@Ntg{|9(oJ9<*k4tPf%9G7M_ z=%t>Xo<8?0U_w8aDGmmDHkpe8S4;!T@dZuKcRB~G>Lt9&2iS#_Ob`VL7X;{6lJ9;# zm4jo-zmb$tK|KJk!%g%7qAIxfF1E_aLXD!mBbUP zM~8m#uJTMee8g-$MS`Fe=1$fLuJ_%n;XAE&e;rtEaq;vIZD@Fzotw9HdrSE6&;o2_ z<`w+E#*fwMdu9lqe%k5jZ|R0Ix57^YI9}mYf>rdFi@fYxbN>uFbJ-;Gm-;LH6~(E- zE1Xhh;DS@b;BsH}1}SbmfOPXLXYb5Oht`3TL0Q?&aodFgUU)ICWeIYaf@BO;^UfJK z?YBEdS5Ai^9L^|e1uO0G&*JTIn;!h#Wt#l8OMDAj)K&&X{BGx+@-0mfU0sq0AdZ2A zX=mpmw!k8mx`qbO*ERg|Qd@Ky`(GSJo%UMqntq1wJxTi?lt>STopnYWjil>Wg7vx5 zfu3ldNPQt3VH?Tkm$Kv7!?#aDISN`BB_geQeQM(u59rXJG6Evj#w*0h*F;W-q!Z7k zt>LoeK4o>e0yOFNMH}|l&qE^y=^d9J+#VM$js`D0RQBi7ov}t*iAp#e2Rg!z-&%k@ zHh7wFA?e#mi=m*UD1)UcSg;a`rRJp7FxX3S&7x=(!;{h}+OvjW}6$3@QV)nQBRAz8xys*}(@DRp)A z^SL_;M73Q4U1J{yvKH3Y(=Y4)C3Twvzq$S4;5+Re7_Q0VFc`WozCP?)*E??)IfK~L%)?(#? zpA@f099Ld1yfXCZROPBQm@*yzL8VYot-FR-ZE^b4bb)mgoed9U!Od!<(dax8**PBH z8-Prs)Ona-CmL#oYA-}^$Xnv-+qh(|69!)dl5mIw~9h1f7t)6)pdh+l4T-r7|= z;3oO6Uz**G>Ge!K!%ZKFO&;%QAyv*@u@#Dmbtar=`~c_N*DuI{^iM;g z7~i<@I+~t!)B*k9(_Ziv@l*b?WU0!3A7zfMb48d*J^{G6i_ewKqPtxP$|;DOkd)>q z$dYH_`SLNIARHh6(=JW(SX~ocR{O-938ehE-?d|OL3ZM`gT+D1i88(%KelF2-Xi2! zjD?cm?;zdL?KM2$oXIamp_rFCJ{LKs4+O&ZlvkY`9mP7$&V!U+T4rZ^CwT{YGVB5W zMVNk!kZ0IlP)yLJa1hnm$^$x$r0@PJ)1Fy7{&2<>bGzGTimA4(#HK*z+7g;6SK`+W zSPI$M95{v!n{-IVgm2zeFyg1pD!JR8P*<+Vey);7r!9+F^lx8Ks3Wtm8Z(jOLg%=U zO*UfL8C;J36a?|_#R?J8DoiNkTzeIKMX88PPCWsK?j|zh_wl65xdR1!fFHGPOh6V* zucilProQ>vsHQ7V7=!!B@p315@}_z7oFThwjsO~(t>bM}YimW39=8N>#Fv=J@N5C1 zU;j(_qQ(gAk_b*-H*kS4wou;or$@29FW2ZUap(i*nr&U6SygKEET z_(pVl0iEzd<=0^*0Ce8{Y%lB>&J1#gFmlxV&9cZe1|;eY0Wr%N53#hYG~H3&IRk=f zx_AOTUCQXNHyd-7KOqUGUz3)}-|C-+&!6Uk(0TKCzAL%S>c*8DAPv`u@0rOR2$H$H zZ8mdxoGcq<@s9bq;DSq+7T5%;ZH6(~v@*W9+{=tx7O6~@2lD(<0aIuMK0K*os7vNz zqucH3ZJr#Tm_6@u(pm&~MqXZC&RBZKhlQc6bT={Y`Tw_$uuP4>Ly9Nn=e=jN?(WDB z@h6M8hHha_Lh=|9gk!`CfIB?81T$MaEBvW-Vg;n%w?E1Em?Jo^YeC|H$HCn#z^Q(| z5Y9fLkXG0_Gx|WE0n(HPF|6}qihQ>Z`6FHIgAeljT?PepP9@7s?|vg0M^3J+EgsiO zm1d|QB|q+q8Ec|nl}5*_g51I7qLo^4J*26&+MZGUY)VqlxbJ?6YaFh_rQBDCUs_tNqBWVLs*Y8gR)`tR;U zxlXe;kB>>L%SKw=_Ke(v|C5_FDM1%OaA|*7n{@A#KaTitKF*Fw_&y0kRMX{-@g88I z{o`5_NCV|15yv%;<{BQZXvpJI=thsC0v$u8AI2W019kR^?51a@`2O?cgc;fuD8%9H ztcYNMN!fhoRmOp%O-Zm5;k$PPhC-@@tW)k8?0G-P-qOe=Qc*;-@%3Hfa#;hLqcM$- zl?2ddBwS})TezQ$#qFCkm8T3Sv1eKQp1zHN4g<3Bjs!h+90@|^@4l(nU*~Y^_!U%K z<03^o(7yR;>1-bGd>}X@e<#wtcF>kpkbO2#73N3&ym}X%xw>i$0IUon4)7G$)cM>2 z*6B)H*Z>42n_4BP;J+GhPqwT*aXp_sU*(y*+#y}gDDFjJ`J0-q8(uw9O13c~J>DeJ zcZ$YJk`%KMo;D=ZMv`^A~` zy|>#ALK+P3?Z4tvUrA&e)%T?=u#+)=j(UDzg{Vga%A?Vx#(c83nMaQ8hmpctMv^$S zaEFZhW`EeHC)kr|kq!XP6j6s=T_uD6EX96s<{pi3$ZULrZ2=@Ks#1H?G*d<0f9sXG zQmT4P8K!#B%8XUzE*qa1Te_yi2b;tD0)zdlU^^V`eBzcvr(uvd+gm#U_ykqQ3RQE zel2L>$8=qb8_M!OoKR|DXb~&7TVx&zzOYH^*GxKW9PpQ6#-b-!Dp2CN_S<++0aPKtOZfp<;i@Px6Y$Y zz0!vkKffHQEFzp6Iv@SG;pRSuRLr`&WL{w`R}z_gHp9Fy#dDRAOOsY{WHP9KzsiVI zgn1Q^ywb`&HE6XOB@+IbY^oTFEk^crB7BORWk@RdoyWk@>W?iCfzkno2Xv>k&X`rh z4iRzjq0v#+!0qy~M}>>gy1}`B?S$LSor{aiyt`v#_$FCT_|rYM-nhLHXL{`;aW!)# zxWKxmh1Fmv4)=4v2I-4hlBn!QHtr~zi}9fv(Y@Y9bu&-Q$0$_bDIqZ+bOR^7PDqu? zjiln0g3KAYC3|7nc- zDz3VYW}0jW{#}1slkU4Q$-o;o?u}^^KkCQp*OfHfz^?Xp>*AI7 z?D`{0{vP3t<_xiIzB6>szFYz_!GE5Ds%$~qn32De_m_D27l!sz=@%A0}c)i;#pQ zBAC6OHlOeo!=KLZD_LXG{dbJvnK4dzxl2*;jgPO%f}gJ6@f)?j3fcM0r>Q{?W~k#z z-+K&)qJ+w~fqjn63Mrh7<7Ci^b-bEh+U1)s>d3BQEBlPhkehPRta@d<6S|~mZrMX{ z$C1%ZBE;bb2JQhg@^T<>IEi9ZRBM1?bIyO8BKUGtLwdy2_io(#d_(G0x}LrXml;?E zVGoLoAX@ske9-Dlt@N6VR{OgWy3wE?+Dumle%@{)EO};}k!lNa10;oCH?U=Y%ih*m zY63*Cs!af&DPH`-2-#=Oq>jdD;aQ7nO7+FQOOp0k&1O6<)8&w120uuaH)p zu*7Au4w(h?1U|%tS}K?9!WZ`AWdlg=Clt|Bv0jLXm6y~;#uRMHYcNE>tPrLql`x#L z6Jc&i1h^!`#o=UU=WLY!ed)h_AYQ2bA1fs^>H**V*fZ_5hpqKm)b&EfF>1kJ{Te@g zs)lPTu_Z<}RR0tGz44#|yxdi~>Y{-kMrxsqCbi9eTFTVGS<`L2gt! z&BuCHFBsFQF_Y>#hUk2xM}q9UAJTDYTYi^e`0#Q1;vYbkqJK3YI-{WWnH zwQ|8!S{Tj2HkE<~v2jr#XO4pD$?mqgCN}LGCxtXz2A&{XEqw*^G*fGG#?AWFtX1{a zBv}@#0lLwAcsJVi;iUdoiNECBdIC$;j_W7{Q~+$NdWYeFida7*T@JzC2MA zeZV|p;B^M;5ihC$g;_Ok*xBoN^)xN)=*_v|J-W!sii5FOkN)&B*)`Tfkb%n=yeY4caBai=i2>t*S+ zf)ejw#oU)wBO}$cWWzb9kMvjd<8u~`(NX1G5I0;M!YD4|DRDn}4m|M}bzzeL`JPlhKrHr4GnN;z+2H@~$|G_ei^~*xc2qDZ%>$)QtNm#1EdRS z0PU_H+E$=c#(k~EsN}k%thi@BQVz-D;U0r3oJbf+kpeMBJ<Wt{ zfYv?{`UrnB&TglUOPg}HMPmB?jha`LFw(%|z_;ALk|bY1V4867bK3}V=q|N-mjTFzPBox5i%$8=d$Jgb* z1u<(EbopW>NnT(u^F}L$f_=`L=*yIP+e-FvH0$gC$<42Ct$eEwxwLBBCB94NGp=L!chE)OmkWgUx^AkB8Z-an)AvurB(L4RRsM_zG z-5SFgjM$I|D!sOO27`h01IzEGTtDr!gs%(on^=d)C71G65WX%$wT=M|V7$|zI*x$) z`dWZQA5EC=GuIn0f!3@X4qu~P*-1BfVxy62P28EcG%_~c`gLZm_sT)90L=Ye zf&%za#HTpYv?tTeTrXGEGNos?;Id-=nao%|tN=qp(!A|NBspC1F}NxEXOGYh{`{0i zT_2)p6^A?TeZtG%Y-yezloIrXj8_%}=JB;L_NOw~-zlc}Dh(e-2I&VgN5!Kj zOjN~s`VpE%c|sL9hNJb;3vlEF52aMzu|V2yr}H<%P9la$k6gUrXGR`fBplQ9O*jL4 zXDO0g@iK}9og5LnI!OLh>7T%TDfCsQ=s;rk@8F$FdQW0|Q(=wkk%5aav@4Oy*rI2f zE^;x*n4jdRAB)yGNS2+AC;APwK9jIAJ@0i=9j!*5h2TU zYVfl9`q_>CTTOk1aanr}&vTH~aB1%UvH%loSKQTJI7K3-4>U+2mDmB1sk+Oe^}uS_ zR#H^1oHgXILnH*CP!J2-bh|CI&*+)s-8YD$9?6;2C+f55*A<36M)>~bi0=cLRQ~8g znAn=y_s6|e2=koDu>jTcp{yCT%4+Vb)q`R;+Z3Q3=Wx*kR5-={X;Fj*^p;+6RT zQ}}h>GE1$0M>Bey6MS(~;heLfX*ETY$Q$-^;;kru(ehG12=vO+>rvohb{6>`;glp! z*4ldBe_k%WN2^cG^v`^CGvFM-o5oF_F7(bf`s9sF5Tnl>OZ_8YmqEeVqkD}D>Nu!F z>bQ3-B024az3B@sG*i}uM%rg;UsRvtCFGsrGoaiAb-Sx~KGT!kNHx=MgQH;shU%dd zqu;EX6x|tv(aw7~qA{h%oRtr+eh4ny8OgHTWGR#M4-`W%u=ySiX7(|&Vg z{uk_o&c`Zhpw?%BH0$1vcnc$mKo5_I9oLv^U(!Nlxwa&%?-aaTVA%+s4InW?aS(x= z8kzmt#psMXpW6wkc~f+(Aw{&qu0`sL@>daeSRM20dU-}=H#6#0-1&qn{ z;7ad=SeZ(BTBb~SmbTYA(lsm`kXZ2`(Vly*>UPTuiy1VsWDaIt@*z*$)+y#B%V`Gw z5>Do$wHN#^R}^JlhUn}s4&sJOVdCXrQM{NzHYhrRTM;`(y7CZ3(38+AK~}jUf1p7} z!ffJ4K1lf|UBHAy2C@LXprzK%&!+8N?IP7j-GA?&EMn55wVgl11I2W)_kiDc%E8k% zdD`Wd3&nj(0_I36_9xOtUs7(`P?SH#YviI(_(@yV6imHoS7LQv1^D^-wR#-A!Y6>a zcy8)#JpLY{HZscIG<$w^0}BG)Sy3&#Gf1oxpmL4vuK+~;2Tk)m7 zf!z%FHY%%@Jh3U%y;HNU+^HW*Q6+IiA6r+Jzim>k<*fU@?%8I5pVASgSfcQ@yv6Pi z+G14Pew*+x(ZSS^wC+h^o*j@J+5u5aq+E7Q9}yHrPpHteV@ktXTJ z55m+ue+yk8H+*IKC828&Q2k^*ij;4GgJ0fAG9$h|v zkQ{Qn(D|ddon%J`I+gIY2om7cMi!btT*v6D(XcuQwnX0LzD$Cg=cd@u9Ij~OR7V7f z2`#m0nV?lE75dw)##$ksnVDgN)Ik~rX+!f!DcP#n@)eekg{>5>H?20pJhEoP1$aRY z1Qs0XWa;uWCzkdio)9|`b@pvnvV}dGVaNpRYKVogw<8;!&22^tQ-<3pDiE|JJ5l6n zK$|fJ(MzNFCffQBC_a{pG8BJb(y6267tfE{ODU&qH)t`ci%jjC%IF+`%I+#V14c0?9LPwz@b9kK3q z#(GU32Ln5o7Asa8IyZX)3E?|aZ(d!I zGn19wj^@FY;3LnoiIAJBo_jj`e@lsE(goXtPh94W*w=JLUJs0Td*9LMR*-^@1a(2y z6Mff3$l>MP?M4b7i&&gir%m_wrOIthELmf#_TSZbPmk;!iGQ$to!2pb)^BY?EAD<| zg<|F}P!gFAko##1z^#SToTuJTYZLH_Rugw>e>w2JuR!z1m2+|&1Ukqk8i?K|X|OOO zN4dI+zWW>e(Uq|3nj(@H0tCLI&Y%X!x#KN=GbKkA7XLeC_P%;g0OnP}>h{`jEn;q*9o z$sAHggBVq3+<2|2`LqsJ5rNAC(0sXFG{hO^tt2-T5l(`S8oU} z85X_kS|zh&r^QxUT~L-g{BEfv41W6N7(igNao#77zaWtOb3d* zy5pkK6F0fI>-1}CF}nh%d|(CX%{vKkfu|DMB`dC;X40^fzKRrmB<8-*P(k z=Onf~LzZJ5v0`2ZP45W$IsA3XmVT}Slf$Etzj6HyA>k2T1^QSfKj@;0SeuSZGj1oW z(Y?Wdsa>9D^1oK=bH10^%#o%$EGsa|#`pw=^5a3ZF&kVShR+*{Gn3vO?1{K>6`?E; z*BIDq0^I{dTDg|!V|8&+Cam};IylKKd6w7uZ*GL7F3#Y7w+GM1XB%gat38)`g{%K? z#dG=N`+&GSpC`1=izTXkTB*V7o!1g3RhuA{tAIRZuL&t}b)u+=E+cnM?Z#fB!}ta@ zE|b&9xqc4p*CNW#LU`svf+Y2}BE?L_7s-Yda{xWhrc=NB=B2x!w)_t{KaPjSyr?uJ zPeFF=-(QPMSP7t+1w3^Y6nVt!AUDem|2(ADZF0p83OIJ!0Sw>c?NPUv z3;au`ZI5JsC{OVcFX?n_y;Qf?f6ctR38kqi3}L9>8U{T$Z}vRZJUyBEdMltJvTM?* z@F#xL4Md2GRKj;pT;JGq>(Di1=MX!<+1!!9o;m~TJ2Lf?P#Q|AaN=Gey;u2#NeSzo zqlj(%1IA@nU0cR)zYPPmqc;AM+>Kvzh2{PZRjfO8(JLfGe<=fhj1hm9#O>c^KRg6~ zC;4Et^4?pA`Jg>9IpvbX6XjArxJAL$U-H5--~Fr26##PByhKs?7MVTNl@e8XfCXXU zhb9(gE2B@oUr3xXx6e{L(x-Wo<-=IF1xkkByUd$s^CZ5!Y5T2XW^A}>10P{D{3-hDYa6u6;JKqzoB}I?{35Ld$JK?kR~9F^V(FsVS|s!GgxV-% zRTpk2HuG|aRs?ffYPhdx@mp0l5BnQSjm{cJ)nv)wAliywH#lIj$T zG1N4Y| z!TtG7Yd?r9p4W`07gzFVrmsfjYC!21q1%%uMXXxJ!c9Q@L?E`QJ)Wyf+4%ksRtL+U ztlUZjS+s5q;?eyLWHWH}|BlO=S??S_@B>*TU** zAEMID&a1;3&sQ1{S~!{XB^f30f6$57bGgWUuGa%+>V$1j8%g_zSBx_uT2N)v{t32% z{IDU3=N(qpe2}8TetX|$4AU6P5{ZlJ3l-VkAM!G`{d{>wj_q#)Z-BQ<>e;>(1`no& z+Jl^0Iyh)ORBrbR#-qxr9I~Pma z1Q)jgNy}FLtk`lG>*i^vn*sBzxk>hCqUJ z@5dd}5*Z2qoO=I3Ujolr@G>Sp^HyVSwP3Jd-n5xjF9K6Ny`0RQoe7@%G~gmno6>OS z^Q5vfxS?&kx;i4R>%VZqc<5j`*v|`8LwXwp=#8kz@ zkF8PRc8!DeW5o}oktrP?qyh(JO3V{2um$0HAAghLP~jEg6x81mTdflEOSqoc)urU| z6lKkgL9$#)E^Fokw6Bi@57*gu;Y_!ZDX5@APN2^m>oV*)e7 z{Q0s-^U0~GC%nad)^gH|UQYMW%itjQNtM+IPVlESyjL)LIrxd>H=8OtEH#0fA#~}Y zD{_hGSSTzz@6FBWkHm_Y5=$hrdK1DW`oA-x7>cO)h95IJ=qv;>$eq5D*cXN68`mJe zDEKQD{q_8|7G3P7P$deyM!q085FWEuebn#PqRGq0)ZMHjp$2b>*;?k!W-E((gdY5f zidUzw(Orz9UdR5{;yFjlXyzXs*JIRN_vS4_QxJ{85sn+LzWBft^mm2!*loaEqntI~ z@9G&-X_V-&At& z`dTN}YZUBz4df}NdbE(RdvdZqL)JxplZ~osvNg(}w)Le8a`n_qgbOQ(yLM_b9FF}^ z^C_p~rWolE^ynO0@*@-eHL=FYds+je5u0Nx>a5h-FCNSrS(HKJ`Q^?Ns5AMQz~c1u zc6qd0F^jomTqMyY^|w15_uZM&k>KbmsC{rvj0=NkEKD2lCgF7gINF78RxmzgU`mws zT5g;9yDaOi*qLx6$k+OtBbzWm7?bShp%GM!gfAZ}_(uf2nk=je)Jq`y^%NyCvV#wQ z8k85)NYEv=j%rBE^5Y%{t~W--yc?B@zl4B_;qAlifUG#x4}o2;3FXUeZ+VI+?haG? z?^GTuBx3Pj`1@-ebqQ~_;Yt5}6SH4^1P?!Vh|KAtYW1)upncp|g!VjfKR5g{M-OcE zxt<5#M(p)OgH8PK5@{s+2gXPAx&24Mq8Rp^pz50N*i1rf`9MRk$0#o)+JK$e?k_7S zP-t93>h21ta@^>4<-U6}RBi^wmFCh7G?xG-b$sVkv>OwwJhNwmx1Hj|r?0xxLbU(1 zwy=3!Y=I3?gd$WH2v5U)vsj7)Sgu3W-tNV(m;IqnPxU4Sx!`7Cie92=QkB|?f0&_h6$tS0iUbpy1XYSg;Wnif$`_vcT{sx*e zV(?6h$E){LXG2(?lD%ujy16Rx09=U{wW?GpWxKav4yxJ^sAz(rA^*zC^1$gDP2jF| zAo}xT5Ny4h+mRVzE`<}2rpMVo!O2Yf$GHPY&sKw|5E;~PpkCTG z!81j-%UGM}O_TP%vD9UWW*dg(;WXEeGS_7A)Jhx@6#Rt-FH)0DWvS1+w7!UMUT(?QESg)`59 z@4P(5vw129d%Qubkm(v(n#a_bA@3E>l3}4rxVBpaE7d+3w z!cRX6w=!7olLj8&!rX+iEbXl$vs&=aul^!fz%%5TNcxHPDTDYQ2^E#AA$7?SbT@7~ zFo3Z>bPXBNH!?BrGBKa$-t<*ovxFn`XrT37U~#P?oq!w2hqWO{drm5!t;|v z$Q;Uxstri%uHC+2;c+;xCxbysucaap6^5~as7Dp4eHLJNmc1~liX}_v2MO~}JoEW$ zx0$cVDY_st>VrRl+#_Lv*X}pB@-lZc4oks&Pv`k@`UUU@?TXnYT1t2A#t#`zOuC~< zTqa=ew&A0BUz%(5w1V&{^V(4Ckd$yEk_2+0%~m>eNM@%$Q?AO7s863nkJZ!@`|x}s ze~pKGs-8^TV}H7TZh2 zHb1a=_b?{L9EGkkJAA(w!E;|@ONRWQJBGVuAK<@ZcW`tx_p`M~E%-EOFtTWh(MHADd#y*j|ol&A= zFT2rnvjM8MIJ(zx-vuy5e8gaBK{qc&wHTkD`=M_aQv-*#j4iNUlo2gSU@Gz-oX-d3 zoGHTbUQClP^^~{fmLun0JW`ly*ykkMs(yJWmTJ0PD#*5*UZ)*LMR{(Rapi#2ojGfV zQDZ>U+UjKL*xMEi`lq<3H;qv7JJKFW_y(5N2wFuxCs_Qj&RFYNVRd(TKh*yT1Alt5 z8qAtp`j`J_waWAec^H203Pr8we*5O#!ykTGt(6$u%1eRZN;1BnZkGp@hrNJX6KF%e zjs6H3YS%3FMnArzYp^b`Yv2XW`8+Pnq%F$yIn8wWwnO=+!*?Y zO(8~v?wVjo2Hru?#l!9LB4t2j_5bP3wXzOP#i}nbs3J zC>3L*C+a{Y{+OK7Z9_-uVWvKUklIEU?BY1&K-&J=~+yyDe^F&njT|s z#s;q=(_CsjG{<9K&e=RWdWu)G8}^N&7NDKhBPOAC#ouG6!xkr~cFCrAQESODgz}z1#u*>F(g2 z9nx2N1TJ0e{k|t3I#uAPYp%xcn4jD)CEfb;kWRo62zN=xkvp= zEXW_$AEzzZOXr&D4VTLm<5TkM+pX?~Fg$#QA4xES=CzjZzsDNL9(A$!XDvljVVCvI zAFwxW)R*t?JdD#7Or?xI!e|oSe1`B2(ChI=zt{9Xi#lqO0?wVuaz#Qc$HuR&d5z#k z_0S6F`>zUH=Kvc44}hV>h0>0Z#PI6cM@MZXuGav<*TDi^w*@&-id<=(Y zG${21>}04|c7nQF1U3Nhz@1ySQWBQ4kl$T?abxYD4PXiL zcqdKovoaCcME&uA3O^*n0Q0I3dQt%fra`|(YH7|hGW(~7{>}-U*jyVieK(jET!&?* zAC&gz9`z;ON)2LdZ$dvpITugn6YBz}4Zhr2oEX2}?~#@wA7n7VHh88fn|W`V4c@u ztY@4js!`SaGfVkLVlrNve(`O*wFY6nwDmbFQ^#j;%dfoX2`o**gAYKKzvv%5vk7X@ zW@^CtQr-P|kE6opia}R`Pgy~i>w5q8dYOyEF7VwYVxqoxwl=hNL97Qxq?wE@yH00e z_B@}iJ_`M8QD+l7W^i=0`>6r+Fv+u`g?$51ebCH|+Auqb$OhS_iIcC*YYm!3l)ksY z;$4l7e4m&Sr2&Ty8c07aef1&MX1=#LJ5}&kIni$le|z#15l|~1S;{P2Wei=SpU(<6 z6BRTPPj2#SwT&*Bj}sA;G$!y@ZHpRkV@X^ES!Q8hu5E*wrBVlNw?O$59}N*Z&tOF6 z(ZBS~+!45y@cU+1GM40z?6$Nb<8hC?&71S*718Zl(vq`*^=%9ZKm>htElsj$>;SpS zU-=00lnl;aekiLH?nn4mPCNv(#A$zcj>}TTZ3rXEEhk59ndq+X-$I4&MStsUr!Ddv zUi@p9kAZfSJ_ed`b3VtPf4S4lO@Jb6*>7m zIgm2{xyINPlWMa+i$T$#g_o(h6xB$@ze%|nT1=kPG~_w%qiCHEa#N&{GWjgGzOw027#>_c<4 zp7@3K7ysv_11+F9d(W_1oh^-gTb!XH><-?`eSAS99fjapasm{IrO&)4G%X4bA3e8k_h#dI zKrYJLXfKOS8u58~Q03)WGf8DHam6ZoFPY5gZpdCBrG&*jByw642?h$+m(T>9J%9Ux zctA$q71$YBPpRTKYZ$j!Ip8m)(G~y|iDtiYSa|Ei79}1u>7F51VM?XlR?;Vjnl$9w zHE8er)b?-J5JAK={1bxi>^{?${VxmfvL^zAfX3`@pI$fapZVqY$J#Nqh+B*j60yS6 zi*+$sklJ=hh1~qRsM?!RazfF(Cyij`Bx)I&uEJF0q=-M*+W;{^l)5}Ih1Zio${RK} z{oL5&u%o4&DK)OaLwJX-_vM?}i()nUgU{6i%>;rXcIXgJh9=8JY}qx&6}yq2YX*OO z1MjZ?TvDxKICG@!PC-*{;LWEU?trqWw+7QCfzPETS)lWw)LY-4YV*5xtxG|c#2i;O zgK_HYr6x%a|Dim^XJq#E)6l}6S|sxRB4*mUtzHwDF>ZFR(!P(iG;hW;)u*bUfn)K-A9tU;S z*Je@?=>t`T{Rf(}&P)96>ndmHUk&N1fBkrr`wOD)<4oVX zrmqceUQzRcV)JcpBaR2A3@l0lh&dys7e+7S$pZ7k{!$9tIRt9j2L1AS!Uc2)z=&WW zY~g_nrNh_)l67LLmUe_Cfl}I2AG^$pILgvo=qq(xLg{JhF`8zPzFMmNUgmxcl{x=< zsP%JxS^8q+QxA8Rf(jQ+Sv~5d>n9VZ$urY}m7NZyiLXO{8r^i_mO9HckIa+3wJ~oW z4qEK7MkH=ZbPSxZb`5xjY%(QMfYEISf9lp=9T$ieX68C~DK@>gzn0kj|7d!vsJObW zX|!>70*za6hhRa2y9Afu-nhFv1PE?Hg1ftWaCd2fHSVs5=l%Y3yKnXwbB#5ns%EWV z9_es1he@tr;zz`y`(K#{;kp69sO2oZML#hF5O@vrM<--Ro<5!+XxG9f_h0Vv1HfvJ zXT0K;P#JmjX#}H4}Va^MT3rA2YV>A=}y3)^*0s z8itu6?3H9xE(9aWb(4o@=k>Bj33>*M!KP6{H^aj<$TH?$W!K7MO$WFOm(jdGwc^H_ zOlzn`xO|~9;^xc9yaP1DCM|wNTC2^+wbu*JR$vHYEDAA*`Zq%-#I$?Ko)0mAW577K zXmxHm-Fzyw4+<7N6Eb#@pP#huu%oyU9clM_by5)$fUN%78{cqN2{AyeEqzvE9!%F?!GzTfiudHe#ye8uvmg=h8Ul z@&c5(8#-w{o{1=Xbhl4hpzIF^sqP_nJ$Tb42Z_ZRS-g04rL7#m87+HObo5YBenijz z?ezRV+3`PwnOpB*;k&W!Nbmcze_o!EA8c_&byaOmSF_}b4>K;s`AV7h#Fk|r1<|4z zmS`90i_lao9b!){zvzHfzEr1Yp4LJVC8g{VC6ZJN5(F+k(SXE%HtKP?;jF^#Q}NT4 zBy|JZ#CDI!2?Uu)K}Ub(qQ!Vc^Cz}_hM-5~-#A%u#lf}tpz=K$ZBS-ELI1Ppp;m;h zzA=HFEC3z4?u@g)q`KNo?^@k!@c2h-X+MaNME=K3kDwL8m6~T3QmJs&f}ipxO<_mh zRp;NhrCAl$dt{8fp~qU1LGZF;7K?(t@!XdOmq zK(XheoO@59Hto85tdT|+lF_x-`oh##dNLqYf5@(#{5+V1St91{5^gg3JesFVDDNU& z@mwZiy3Q{bb?yJhe;`Cp30(8N4Jy6BlY;&6H8taEKl~;1SZ3Nu8+4$@4=N4lx(`!3 z<1CS$h4|v+m1T0r8=y*SG-S~xbJSy630YFB4`(y-dUon8o+RYyWe1E9?cCD&)54$k zkJpYSkeMCTG4$sR#*}g!GcP|=Xtdi_E~f$Y+WE?0i4X)0~uGKCqq$_`&~Q1%ENTD8ARW*AxK&brm=trs2&uK_yyvi5kaG>Q8(yahm4~m;276X36N0Tc8_clJ4yfS7E)z|RH zT7Ch0MT+grY)zK8u;&E{^!>!&ZN0a0-p%D0qPOJp^GR3p;dxKsnV->QgnvBj1jkEF zTHFSYPYG2{&?w`hs)q%4DmS_>Thh{7WL8pdM|4CO^{Vj<2S2zV2?V$^)ctEdjdCtT zNB+DZWQyrSEo9J^iU zmh!0DU{h3%DYS~JzQ%g;xy~VXDD)X7;%EsE^R8o!H8KAJZtOnv=Eb^~!pU^vcrd{a zv$OVlC!%OVvPO+Ul*UJ}bI$>A5{buJx7kFtnqIM%U7Wwn+x`-kw z#Uwqplc~+r>s9|y9=$J>qd8vk`05C<=!LEdUFnwM%u~%pL6YO__X6nywb&K}rq-o8 zcKj-)e$x>Xsl;Gsmn|eoqXy&?vL@sV8Z4US3j$McZB+6d)1T<0dtrZK)wzScJ#}@4p}$yJNBqqGG}EYm{Dfoe-dcQU_N7aa(q|cJyvE&5G%zSS2R&9qnejEG*P7S)(ET4(RxH zp+tiDn#f~*KYu^Q`YXhQ85#6R?7tA0)M50%k}weJc{S_s=uwot7!Xj&C?4-vLWtOTZ{lIalLQ{+Hs0CBK4U*zDEtJ`Ggu$E#TMr+{L zXD|NojyDm^C46+2Tz#1 zR2k5CrJnZ%wfX>Xm!Z2c7-EfqfeCHp7!Mj8_&Crnc z!F$UfX<>$(WBGfXbNRicPwkv>P8rqud`#+6Ep0k|mCv{bS*cn8Nrcarnh}hE48SZy zvK3EdCE+ci#v0vKqQhBKBL|Fg^XFE%#bg4nvhkxR>Qvqz6TyN%+2+Ff*D1&3N@AZY?nmLCyneDizFz_4$74r}iBWXS{dBIwQzQagkbV zp6;lu@F+eEb@RP{HF5y-hYx$SzH}1)@y#MD5XJl()jRZVcbxWZK5)6Hv+oj3Aw9&i zUH0MW!9I76vxd+Q;lr@1lB>!7FJ=TeqW?w}xHx;y;CaDd_+vG!rbBPX>k@IPoKaax zYek4l17ZyNdpfp`%9Ui5UjkGFymV!pXTFNYG&trbqwFim?)ZI$f$P&sLPeisEBJn6 z5o%?wzK1HA{-<8)wvo-|_l@of=<$9#vdI-2oYcMawG^FNwApd%kDpK1Qq34~`r(yj zxy!rXA4BWFr6d0e+92J$In_UWZN937e;4g`o?b6sGsSA0QJ+arJQDT5ddlqimPjRF z{CD5qtqZ3Z5}C1ir58uvnIP@?^es;PYThrN>H}=Dg=qZan|?C*|O$32y29ZdAtc5RO}B8WYW~?xl5!l zN$v_fSaRN~&@Y?+P{lqwHrISOZ9P9mWd7o6eSnGAQTl?4=@oJNKDF7-Ea#-B83byV zGTrA>-%UGw&lxVFmJL`=@{(l6YGw#XdNSI#9sm3bPeA^IoplZ+ z#j3Y%-O*r4?fVRqMkifu-t6SJ#}`M#C0Nv1F`5!{bL(To`s3D7>d93JGC)|*l?;mm z*qB=JN@)M^NzZ8`&E$vIuu}qyRAAzsEXqwo#zT5NzRnm~O)~M!QDr$nU0TA-vG!Pw z#WNPpLR2;mx$9&`I)l*7)QM?(@-2*%L$_1HuQ_+nL}hqug=eSS z^s-2e;{*v)XIPo#EQ9%v9a7_2bQv6gU-^yn8a&HEo79HgqQOlePaaGnZ67H6qw4&1 z0&i{hsS{6R4v!{XF#iWx7JC!G@$nYeINRtt>773O+|`e0y?wqG-!50P2-_#Er4HeS zdkYFMN@YkyOV!<Ehu9@vPUSu()~OfF}VuleR=a9)&lP}16l z+;cwBa+chBEF?U!Iy_N==ho8-cmVq0q!nwMm=EEtd`r+Dq9Zm!XKJM_{q&I4Kdzq; zh^n2AAX>SJX;cGGgp;NFj|WHET}q%H zNC!m*Lxowd9_?GK1LK78@rChAB6+cftIzR#kVPh)({`^#d56Gxb$ZEoI?~$97hS2f zeg?j`_BjhP-Q_FsDI-qhNg6PzcrJYGYIp9m=2I=H{=d@4kL#VyH5PC9WE{JCd*H7{Lu_S(aSdfX;c&d;|N zqBL5CzSgvVf6S?y3aGz#_;;LLh&ry{r5`loK#YiGN4)9Y!X1o7L=!Zsbcg5XGZghH zTdZ(^FP=8O&~ziI*Y|}`oe%un8jtqx_R(p%=OePi4;w$bpB^~hW)R3^tHH0a&$m&} zud!CTHGCKqjQh5Xqe+!;B>&I(|IXi9ByzNu0O<^SkHm;?6zf&f!6U?h|5g?vnfc`Akb<$Uupuci}hcDtB*FH zyn>j$Q8BCo^3JdVR-(CvbDvZ!;x!UWo3ZnP7q?D8>)@Zp@xq_%r6=X!U>LR{=O1Nu zBtUpv9a7mi(N3e1^UH$e^Bdl?I4E59gN1M?gv16c~fqzfvKhrAmom+XHXi~<5X zL#%ZTdx9iywoo|6Zv#ydTK=`kZGUSYwU?!bFL0~dGiYUA;3bf<(ej+>fk)sySAm=< z-rtG{0D&R)*vly*|96b;4_WU6pV0e@vtG3((-M)sewEe|m%Jgm148sFGc08eo{^%Y=>hWNoei5>;siC%vr;ESh! zLA>^ke)f`%Gg-K)g3^_HP$^bXmfD&fBRr#*(-o;4?iySowah8~ib=6g06ZbOzXgMC z%WhBPylnL4HuN}uciw9RAEiuXk!HBgT024Xtm)Zewr{-*8@lYRHRuTr<5_{GJ;f=( ze`o0}7dre_$!cjat(&pL*}@rev^I#^zn1h{AYI&_9JMJRM3uWBX zmyT;}oa=06_P`6whi~%Q-BnkwC^2M-H|sQcI);d@hq|kzz#7#4JpTm5MfBM*i^6KB z2C4{PKW~p69*cYu3-LYMvd^ZdoG5{(RQp}%6W`aIDUa7DE)u{6@FUB<-#i@0b#IZq zKhS#IpXqiypT(}-1U+8`?RE9FR+Ld;u#0n3qhO_1A$BY~_abYDm|`f^XowJJ&f-)K z+7-^$LDbMewelIvaw75!<_VYyc=|t5obv=o^rN9H1Z*&QOkJx7y5SJMcwkc^d0R&F+pCTFh`uS z_IN0=TdA;@OQ1N0pQQD~FHtSnOf48MG7xwLeU1=O`*i~QSt7u!h9@amhSbVvgXeUu z;lUEFhH8TfVicqh6OH3|@JW`@0=9&^m{q*~xb0<;;nSUs0o}6Q!(R?US*WY8Zx!ly z3o@9@Vz2xgl&>d#hIZ)}{cy~!%| zfA(zPkb2=ZrM1BFqU~+gAH8IJCM}%D$IqcC0E#l&7((TaIcHvncrKxoop%#n;4dXv z-M@Tx6;vBZnQkfGCA1vNplg~LcR%jBQmcz+-;Z0dun4Fz3c zu)W>Rf@!z8-0ooI81gN~=dmZ|OSZt#PxR6U1*I@F4*1GV^f7Y2iYoaB@D7jM-_3A!{foYisNiHd_-{G23IqXw`8l+7ab zkw&!+ir&Hq@p(O{RJ)Q;`Eza=lbsIvg>OFGOO(7Civ%IjvyBj1$Y%YkcRARdm9-fj z(r5fk6R@yU4Bsjka1aR{VIBQLi@k)qB*6C|of^i>WjxZ>{zrMnicRU9E`%4^lX6l`vz65 z7fW&kRK!FyM()=luNs>p>%CSmQZBElUncB8e!LALbh=Ue)j4nMp*Iq2oS4SNQ`>x} zzy6V1cmfG7h6J!x=>LUV_{-ZRzQNUS&MPWe=PU8pP0*p&;H}V(Uw5!3Mt3ELOq%PvSi_PGRK%TTLS~SkIULY^e zarIMezxIvy)r%0&GX_TTGKu#s^9q=sd#w~z-p_Fync>a-sK$36HQUXY@HD{C1^u&U z4`=Qk)K@b9y>9qZ<;ealNC1r-oVunPT#o4S+u2yuMoq~|NKds2bn!9SB)=ym zZ-f-XG$BAbxEq#9W>R8(ULs|lk(*V6s(^XW=$f%(Y;Qg7cI+^Fhs|tvDPDou9j*BI z82Cy=)e?=zgmp1v=tw9D7p4 z?t(m&=lBizU;Xp%bMJm7Tsr1w`bZ#MCOR5DtphKI0C&nhKp*N*?stLN-G^+6ZbL+$ zNzzX+Iv7FpO!UTe0K$+IXO?=nTBWENz2z)tibt5z`HIZl<3XWraMlbV!T7=O{*5=M znpXP(T)tWTEDWd-S&LQN>k53d*#|Fn;oI@5bKXhaH-plp(Y_cIrSWbaM{rCpaJ+LJ z7};nExg-=1I7y7Wf7Tk5Lf+P|Ha^ah04�Sv@k+^gBDG{+jTQLw+tusY?xZ2j_z} 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 T#L*l4?^m)?pCzlrzXbmu_+fU8 literal 0 HcmV?d00001 diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index aec7571..94bdc93 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -16,7 +16,7 @@ class Ui_MainWindow(object): MainWindow.setMinimumSize(QtCore.QSize(1280, 720)) MainWindow.setMaximumSize(QtCore.QSize(3840, 2160)) icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap("thermostat-icon-256x256.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) + icon.addPixmap(QtGui.QPixmap("thermostat-icon-640x640.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) MainWindow.setWindowIcon(icon) self.main_widget = QtWidgets.QWidget(parent=MainWindow) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) From ae70ce7e0bf4a01ef08ec222581966d22b60a070 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 13 Sep 2023 10:58:40 +0800 Subject: [PATCH 205/247] Remove old GUI and update docs --- README.md | 12 +- pytec/tecQT.py | 335 ------------------------------------------------- 2 files changed, 5 insertions(+), 342 deletions(-) delete mode 100644 pytec/tecQT.py diff --git a/README.md b/README.md index 24fcc45..1b19514 100644 --- a/README.md +++ b/README.md @@ -69,17 +69,15 @@ openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv ## GUI Usage -A GUI has been developed for easy configuration and plotting of key parameters. +A GUI has been developed for easy configuration and plotting of key parameters. -The Python GUI program is located at pytec/tecQT.py +The Python GUI program is located at pytec/tec_qt.py. -The GUI is developed based on the Python library pyqtgraph. The environment needed to run the GUI is configured automatically by running: +The GUI is developed based on the Python library pyqtgraph. The GUI can be configured and launched automatically by running: -```shell -nix develop ``` - -The GUI program assumes the default IP and port of 192.168.1.26 23 is used. If a different IP or port is used, the IP and port setting should be changed in the GUI code. +nix run .#thermostat_gui +``` ## Command Line Usage diff --git a/pytec/tecQT.py b/pytec/tecQT.py deleted file mode 100644 index 289ad01..0000000 --- a/pytec/tecQT.py +++ /dev/null @@ -1,335 +0,0 @@ -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.parametertree.parameterTypes as pTypes -from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType -import numpy as np -import pyqtgraph as pg -from pytec.client import Client -from enum import Enum -from autotune import PIDAutotune, PIDAutotuneState - -rec_len = 1000 -refresh_period = 20 - -TECparams = [[ - {'tag': 'report', 'type': 'parent', 'children': [ - {'tag': 'pid_engaged', 'type': 'bool', 'value': False}, - ]}, - {'tag': 'pwm', 'type': 'parent', 'children': [ - {'tag': 'max_i_pos', 'type': 'float', 'value': 0}, - {'tag': 'max_i_neg', 'type': 'float', 'value': 0}, - {'tag': 'max_v', 'type': 'float', 'value': 0}, - {'tag': 'i_set', 'type': 'float', 'value': 0}, - ]}, - {'tag': 'pid', 'type': 'parent', 'children': [ - {'tag': 'kp', 'type': 'float', 'value': 0}, - {'tag': 'ki', 'type': 'float', 'value': 0}, - {'tag': 'kd', 'type': 'float', 'value': 0}, - {'tag': 'output_min', 'type': 'float', 'value': 0}, - {'tag': 'output_max', 'type': 'float', 'value': 0}, - ]}, - {'tag': 's-h', 'type': 'parent', 'children': [ - {'tag': 't0', 'type': 'float', 'value': 0}, - {'tag': 'r0', 'type': 'float', 'value': 0}, - {'tag': 'b', 'type': 'float', 'value': 0}, - ]}, - {'tag': 'PIDtarget', 'type': 'parent', 'children': [ - {'tag': 'target', 'type': 'float', 'value': 0}, - ]}, -] for _ in range(2)] - -GUIparams = [[ - {'name': 'Disable Output', 'type': 'action', 'tip': 'Disable Output'}, - {'name': 'Constant Current', 'type': 'group', 'children': [ - {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, - 'suffix': 'A'}, - ]}, - {'name': 'Temperature PID', 'type': 'bool', 'value': False, 'children': [ - {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, - 'suffix': 'C'}, - ]}, - {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, - 'suffix': 'A'}, - {'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, - 'suffix': 'V'}, - ]}, - {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, - 'suffix': 'C'}, - {'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm'}, - {'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1}, - ]}, - {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1}, - {'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1}, - {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1}, - {'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, 'suffix': 'C'}, - {'name': 'Run', 'type': 'action', 'tip': 'Run'}, - ]}, - ]}, - {'name': 'Save to flash', 'type': 'action', 'tip': 'Save to flash'}, -] for _ in range(2)] - -autoTuner = [PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000), - PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000)] - - -# If anything changes in the tree, print a message -def change(param, changes, ch): - print("tree changes:") - for param, change, data in changes: - path = paramList[ch].childPath(param) - if path is not None: - childName = '.'.join(path) - else: - childName = param.name() - print(' parameter: %s' % childName) - print(' change: %s' % change) - print(' data: %s' % str(data)) - print(' ----------') - - if childName == 'Disable Output': - tec.set_param('pwm', ch, 'i_set', 0) - paramList[ch].child('Constant Current').child('Set Current').setValue(0) - paramList[ch].child('Temperature PID').setValue(False) - autoTuner[ch].setOff() - - if childName == 'Temperature PID': - if (data): - tec.set_param("pwm", ch, "pid") - else: - tec.set_param('pwm', ch, 'i_set', paramList[ch].child('Constant Current').child('Set Current').value()) - - if childName == 'Constant Current.Set Current': - tec.set_param('pwm', ch, 'i_set', data) - paramList[ch].child('Temperature PID').setValue(False) - - if childName == 'Temperature PID.Set Temperature': - tec.set_param('pid', ch, 'target', data) - - if childName == 'Output Config.Max Current': - tec.set_param('pwm', ch, 'max_i_pos', data) - tec.set_param('pwm', ch, 'max_i_neg', data) - tec.set_param('pid', ch, 'output_min', -data) - tec.set_param('pid', ch, 'output_max', data) - - if childName == 'Output Config.Max Voltage': - tec.set_param('pwm', ch, 'max_v', data) - - if childName == 'Thermistor Config.T0': - tec.set_param('s-h', ch, 't0', data) - - if childName == 'Thermistor Config.R0': - tec.set_param('s-h', ch, 'r0', data) - - if childName == 'Thermistor Config.Beta': - tec.set_param('s-h', ch, 'b', data) - - if childName == 'PID Config.kP': - tec.set_param('pid', ch, 'kp', data) - - if childName == 'PID Config.kI': - tec.set_param('pid', ch, 'ki', data) - - if childName == 'PID Config.kD': - tec.set_param('pid', ch, 'kd', data) - - if childName == 'PID Config.PID Auto Tune.Run': - autoTuner[ch].setParam( - paramList[ch].child('PID Config').child('PID Auto Tune').child('Target Temperature').value(), - paramList[ch].child('PID Config').child('PID Auto Tune').child('Test Current').value(), - paramList[ch].child('PID Config').child('PID Auto Tune').child('Temperature Swing').value(), - refresh_period / 1000, - 1) - autoTuner[ch].setReady() - paramList[ch].child('Temperature PID').setValue(False) - - if childName == 'Save to flash': - tec.save_config() - - -def change0(param, changes): - change(param, changes, 0) - - -def change1(param, changes): - change(param, changes, 1) - - -class Curves: - def __init__(self, legend: str, key: str, channel: int, color: str, buffer_len: int, period: int): - self.curveItem = pg.PlotCurveItem(pen=({'color': color, 'width': 1})) - self.legendStr = legend - self.keyStr = key - self.channel = channel - self.data_buf = np.zeros(buffer_len) - self.time_stamp = np.zeros(buffer_len) - self.buffLen = buffer_len - self.period = period - - def update(self, tec_data, cnt): - if cnt == 0: - np.copyto(self.data_buf, np.full(self.buffLen, tec_data[self.channel][self.keyStr])) - else: - self.data_buf[:-1] = self.data_buf[1:] - self.data_buf[-1] = tec_data[self.channel][self.keyStr] - self.time_stamp[:-1] = self.time_stamp[1:] - self.time_stamp[-1] = cnt * self.period / 1000 - self.curveItem.setData(x=self.time_stamp, y=self.data_buf) - - -class Graph: - def __init__(self, parent: pg.LayoutWidget, title: str, row: int, col: int, curves: list[Curves]): - self.plotItem = pg.PlotWidget(title=title) - self.legendItem = pg.LegendItem(offset=(75, 30), brush=(50, 50, 200, 150)) - self.legendItem.setParentItem(self.plotItem.getPlotItem()) - parent.addWidget(self.plotItem, row, col) - self.curves = curves - for curve in self.curves: - self.plotItem.addItem(curve.curveItem) - self.legendItem.addItem(curve.curveItem, curve.legendStr) - - def update(self, tec_data, cnt): - for curve in self.curves: - curve.update(tec_data, cnt) - self.plotItem.setRange( - xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000]) - - -def TECsync(): - global TECparams - for channel in range(2): - for parents in TECparams[channel]: - if parents['tag'] == 'report': - for data in tec.report_mode(): - for children in parents['children']: - print(data) - children['value'] = data[channel][children['tag']] - if quit: - break - if parents['tag'] == 'pwm': - for children in parents['children']: - children['value'] = tec.get_pwm()[channel][children['tag']]['value'] - if parents['tag'] == 'pid': - for children in parents['children']: - children['value'] = tec.get_pid()[channel]['parameters'][children['tag']] - if parents['tag'] == 's-h': - for children in parents['children']: - children['value'] = tec.get_steinhart_hart()[channel]['params'][children['tag']] - if parents['tag'] == 'PIDtarget': - for children in parents['children']: - children['value'] = tec.get_pid()[channel]['target'] - - -def refreshTreeParam(tempTree: dict, channel: int) -> dict: - tempTree['children']['Constant Current']['children']['Set Current']['value'] = TECparams[channel][1]['children'][3][ - 'value'] - tempTree['children']['Temperature PID']['value'] = TECparams[channel][0]['children'][0]['value'] - tempTree['children']['Temperature PID']['children']['Set Temperature']['value'] = \ - TECparams[channel][4]['children'][0]['value'] - tempTree['children']['Output Config']['children']['Max Current']['value'] = TECparams[channel][1]['children'][0][ - 'value'] - tempTree['children']['Output Config']['children']['Max Voltage']['value'] = TECparams[channel][1]['children'][2][ - 'value'] - tempTree['children']['Thermistor Config']['children']['T0']['value'] = TECparams[channel][3]['children'][0][ - 'value'] - 273.15 - tempTree['children']['Thermistor Config']['children']['R0']['value'] = TECparams[channel][3]['children'][1]['value'] - tempTree['children']['Thermistor Config']['children']['Beta']['value'] = TECparams[channel][3]['children'][2][ - 'value'] - tempTree['children']['PID Config']['children']['kP']['value'] = TECparams[channel][2]['children'][0]['value'] - tempTree['children']['PID Config']['children']['kI']['value'] = TECparams[channel][2]['children'][1]['value'] - tempTree['children']['PID Config']['children']['kD']['value'] = TECparams[channel][2]['children'][2]['value'] - return tempTree - - -cnt = 0 - - -def updateData(): - global cnt - for data in tec.report_mode(): - - ch0tempGraph.update(data, cnt) - ch1tempGraph.update(data, cnt) - ch0currentGraph.update(data, cnt) - ch1currentGraph.update(data, cnt) - - for channel in range(2): - if (autoTuner[channel].state() == PIDAutotuneState.STATE_READY or - autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_UP or - autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_DOWN): - autoTuner[channel].run(data[channel]['temperature'], data[channel]['time']) - tec.set_param('pwm', channel, 'i_set', autoTuner[channel].output()) - elif autoTuner[channel].state() == PIDAutotuneState.STATE_SUCCEEDED: - kp, ki, kd = autoTuner[channel].get_tec_pid() - autoTuner[channel].setOff() - paramList[channel].child('PID Config').child('kP').setValue(kp) - paramList[channel].child('PID Config').child('kI').setValue(ki) - paramList[channel].child('PID Config').child('kD').setValue(kd) - tec.set_param('pid', channel, 'kp', kp) - tec.set_param('pid', channel, 'ki', ki) - tec.set_param('pid', channel, 'kd', kd) - elif autoTuner[channel].state() == PIDAutotuneState.STATE_FAILED: - tec.set_param('pwm', channel, 'i_set', 0) - autoTuner[channel].setOff() - - if quit: - break - cnt += 1 - - -if __name__ == '__main__': - tec = Client(host="192.168.1.26", port=23, timeout=None) - - app = pg.mkQApp() - pg.setConfigOptions(antialias=True) - mw = QtGui.QMainWindow() - mw.setWindowTitle('Thermostat Control Panel') - mw.resize(1920, 1200) - cw = QtGui.QWidget() - mw.setCentralWidget(cw) - l = QtGui.QVBoxLayout() - layout = pg.LayoutWidget() - l.addWidget(layout) - cw.setLayout(l) - - ## Create tree of Parameter objects - paramList = [Parameter.create(name='GUIparams', type='group', children=GUIparams[0]), - Parameter.create(name='GUIparams', type='group', children=GUIparams[1])] - - ch0Tree = ParameterTree() - ch0Tree.setParameters(paramList[0], showTop=False) - ch1Tree = ParameterTree() - ch1Tree.setParameters(paramList[1], showTop=False) - - TECsync() - paramList[0].restoreState(refreshTreeParam(paramList[0].saveState(), 0)) - paramList[1].restoreState(refreshTreeParam(paramList[1].saveState(), 1)) - - paramList[0].sigTreeStateChanged.connect(change0) - paramList[1].sigTreeStateChanged.connect(change1) - - layout.addWidget(ch0Tree, 1, 1, 1, 1) - layout.addWidget(ch1Tree, 2, 1, 1, 1) - - ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, - [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)]) - ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, - [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)]) - ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, - [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period), - Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)]) - ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, - [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period), - Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)]) - - t = QtCore.QTimer() - t.timeout.connect(updateData) - t.start(refresh_period) - - mw.show() - - pg.exec() From cfda30f795151e25fdc214847b183270519c9386 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 14 Sep 2023 17:33:10 +0800 Subject: [PATCH 206/247] Switch to pyproject.toml --- flake.nix | 1 + pytec/pyproject.toml | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 pytec/pyproject.toml diff --git a/flake.nix b/flake.nix index 5c80712..824bf39 100644 --- a/flake.nix +++ b/flake.nix @@ -94,6 +94,7 @@ thermostat_gui = pkgs.python3Packages.buildPythonPackage { pname = "thermostat_gui"; version = "0.0.0"; + format = "pyproject"; src = "${self}/pytec"; nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; diff --git a/pytec/pyproject.toml b/pytec/pyproject.toml new file mode 100644 index 0000000..668354f --- /dev/null +++ b/pytec/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "pytec" +version = "0.0" +authors = [{name = "M-Labs"}] +description = "Control TEC" +urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat" +license = {text = "GPLv3"} + +[project.gui-scripts] +tec_qt = "tec_qt:main" + +[tool.setuptools] +packages.find = {} +py-modules = ["tec_qt", "ui_tec_qt", "autotune", "waitingspinnerwidget"] From 8fcd00292ef03b1e1c2a1fcc7d6d0e3daf208fe2 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 18 Sep 2023 12:10:55 +0800 Subject: [PATCH 207/247] Add the rest of the modules --- pytec/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/pyproject.toml b/pytec/pyproject.toml index 668354f..e36fa60 100644 --- a/pytec/pyproject.toml +++ b/pytec/pyproject.toml @@ -15,4 +15,4 @@ tec_qt = "tec_qt:main" [tool.setuptools] packages.find = {} -py-modules = ["tec_qt", "ui_tec_qt", "autotune", "waitingspinnerwidget"] +py-modules = ["aioexample", "autotune", "example", "plot", "tec_qt", "ui_tec_qt", "waitingspinnerwidget"] From e4d1f0133ed2cf79bc1f7634d0c78d3efbebcabb Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 25 Sep 2023 11:58:46 +0800 Subject: [PATCH 208/247] More decimals for current too --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2f0f7bc..1ee6b43 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -223,7 +223,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Thermostat parameters that are particular to a channel""" THERMOSTAT_PARAMETERS = [[ {'name': 'Temperature', 'type': 'float', 'siPrefix': True, 'suffix': '°C', 'decimals': 6, 'readonly': True}, - {'name': 'Current through TEC', 'type': 'float', 'siPrefix': True, 'suffix': 'A', 'readonly': True}, + {'name': 'Current through TEC', 'type': 'float', 'siPrefix': True, 'suffix': 'A', 'decimals': 4, 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, 'param': [('pwm', ch, 'pid')], 'children': [ From 442450667bb518c731de8c30db31009f4d6b72b1 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 26 Sep 2023 17:43:40 +0800 Subject: [PATCH 209/247] Status bar limits warning --- pytec/tec_qt.py | 51 ++++++++++++++++++++++++++++++++++++++++------ pytec/tec_qt.ui | 3 +++ pytec/ui_tec_qt.py | 3 +++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 1ee6b43..b3faa7b 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -85,14 +85,12 @@ class WrappedClient(QObject, Client): async def _check_zero_limits(self): pwm_report = await self.get_pwm() for pwm_channel in pwm_report: + ch = pwm_channel["channel"] 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"])) + lowest = min(neg, pos) + await self.set_param("pwm", ch, 'max_i_neg', lowest) + await self.set_param("pwm", ch, 'max_i_pos', lowest) class ClientWatcher(QObject): @@ -509,6 +507,39 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.plot_settings.setMenu(self.plot_menu) + @pyqtSlot(list) + def set_limits_warning(self, limits_zeroed: list): + for channel in limits_zeroed: + if len(channel) != 0: + break + else: + self.limits_warning.setPixmap(QtGui.QPixmap()) + self.limits_warning.setToolTip("") + return + + report_str = "The following output limits are set to zero:\n" + for ch in range(2): + had_zeros = False + first = True + for limit in "max_i_pos", "max_v": + if limit in limits_zeroed[ch]: + if not first: + report_str += ", " + if not had_zeros: + report_str += f"Channel {ch}: " + had_zeros = True + report_str += '"Max Absolute Current"' if limit == "max_i_pos" else '"Max Absolute Voltage"' + first = False + if had_zeros: + report_str += '\n' + + report_str += "\nThere will be no overall output on channels with zeroed limits." + + pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") + icon = self.style().standardIcon(pixmapi) + self.limits_warning.setPixmap(icon.pixmap(16, 16)) + self.limits_warning.setToolTip(report_str) + @pyqtSlot(int) def set_max_samples(self, samples: int): for channel_graph in self.channel_graphs: @@ -767,12 +798,20 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @pyqtSlot(list) def update_pwm(self, pwm_data): + channel_zeroed_limits = [[] for i in range(2)] + for pwm_params in pwm_data: channel = pwm_params["channel"] with QSignalBlocker(self.params[channel]): self.params[channel].child("Output Config", "Limits", "Max Absolute Voltage").setValue(pwm_params["max_v"]["value"]) self.params[channel].child("Output Config", "Limits", "Max Absolute Current").setValue(pwm_params["max_i_pos"]["value"]) + for limit in "max_i_pos", "max_v": + if pwm_params[limit]["value"] == 0.0: + channel_zeroed_limits[channel].append(limit) + + self.set_limits_warning(channel_zeroed_limits) + @pyqtSlot(list) def update_postfilter(self, postfilter_data): for postfilter_params in postfilter_data: diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 87bb516..326d785 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -252,6 +252,9 @@
+ + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 94bdc93..909c89f 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -127,6 +127,9 @@ class Ui_MainWindow(object): self.plot_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.plot_settings.setObjectName("plot_settings") self.settings_layout.addWidget(self.plot_settings) + self.limits_warning = QtWidgets.QLabel(parent=self.bottom_settings_group) + self.limits_warning.setObjectName("limits_warning") + self.settings_layout.addWidget(self.limits_warning) self.background_task_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group) self.background_task_lbl.setObjectName("background_task_lbl") self.settings_layout.addWidget(self.background_task_lbl) From 6005caf8b71deac1ba7c595f670f109bf63ebe15 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 29 Sep 2023 10:19:17 +0800 Subject: [PATCH 210/247] Add info boxes when loading/saving configs --- pytec/tec_qt.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b3faa7b..acd93e3 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -443,6 +443,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(bool) async def load(_): await self.client.load_config() + loaded = QtWidgets.QMessageBox(self) + loaded.setWindowTitle("Config loaded") + loaded.setText(f"All channel configs have been loaded from flash.") + loaded.setIcon(QtWidgets.QMessageBox.Icon.Information) + loaded.show() self.actionLoad_all_configs.triggered.connect(load) self.thermostat_menu.addAction(self.actionLoad_all_configs) @@ -450,6 +455,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(bool) async def save(_): await self.client.save_config() + saved = QtWidgets.QMessageBox(self) + saved.setWindowTitle("Config saved") + saved.setText(f"All channel configs have been saved to flash.") + saved.setIcon(QtWidgets.QMessageBox.Icon.Information) + saved.show() self.actionSave_all_configs.triggered.connect(save) self.thermostat_menu.addAction(self.actionSave_all_configs) @@ -685,12 +695,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot() async def save(_, ch=i): await self.client.save_config(ch) + saved = QtWidgets.QMessageBox(self) + saved.setWindowTitle("Config saved") + saved.setText(f"Channel {ch} Config has been saved to flash.") + saved.setIcon(QtWidgets.QMessageBox.Icon.Information) + saved.show() self.params[i].child('Save to flash').sigActivated.connect(save) @asyncSlot() async def load(_, ch=i): await self.client.load_config(ch) + loaded = QtWidgets.QMessageBox(self) + loaded.setWindowTitle("Config loaded") + loaded.setText(f"Channel {ch} Config has been loaded from flash.") + loaded.setIcon(QtWidgets.QMessageBox.Icon.Information) + loaded.show() self.params[i].child('Load from flash').sigActivated.connect(load) From fe6901d35f478bae78420ca167ecea96b3905bf1 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 3 Oct 2023 17:42:31 +0800 Subject: [PATCH 211/247] Patch to avoid floating point error of temperature A more complete system of dealing with floating point imprecision on the way. --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index acd93e3..91299a4 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -792,7 +792,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"]) self.params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"]) self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"]) - self.channel_graphs[channel].t_line.setValue(settings["target"]) + self.channel_graphs[channel].t_line.setValue(round(settings["target"], 6)) @pyqtSlot(list) def update_report(self, report_data): From b4504bcfaaa06bdd7b2fc21ce5f5904349344435 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 4 Oct 2023 11:59:13 +0800 Subject: [PATCH 212/247] Add docstring --- pytec/tec_qt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 91299a4..44ccdb3 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -154,6 +154,8 @@ class ClientWatcher(QObject): class ChannelGraphs: + """Manager of the two graphs of a channel, and its elements.""" + """The maximum number of sample points to store.""" DEFAULT_MAX_SAMPLES = 1000 From da73f537b7cd4e63e036a2a359f818e7a888c0ea Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 6 Oct 2023 11:06:48 +0800 Subject: [PATCH 213/247] Separate min and max current --- pytec/tec_qt.py | 57 ++++++++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 44ccdb3..b8dfce1 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -84,13 +84,8 @@ class WrappedClient(QObject, Client): async def _check_zero_limits(self): pwm_report = await self.get_pwm() - for pwm_channel in pwm_report: - ch = pwm_channel["channel"] - if (neg := pwm_channel["max_i_neg"]["value"]) != (pos := pwm_channel["max_i_pos"]["value"]): - # Set the minimum of the 2 - lowest = min(neg, pos) - await self.set_param("pwm", ch, 'max_i_neg', lowest) - await self.set_param("pwm", ch, 'max_i_pos', lowest) + pid_report = await self.get_pid() + # TODO: Get pid output_max and max_i_pos synced. Same for min and neg. class ClientWatcher(QObject): @@ -233,8 +228,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 'suffix': '°C', 'param': [('pid', ch, 'target')]}, ]}, {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Absolute Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, - 'suffix': 'A', 'param': [('pwm', ch, 'max_i_pos'), ('pid', ch, 'output_min', '-'), ('pwm', ch, 'max_i_neg'), ('pid', ch, 'output_max')]}, + {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, + 'suffix': 'A', 'param': [('pwm', ch, 'max_i_pos'), ('pid', ch, 'output_max')]}, + {'name': 'Min Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 0), 'siPrefix': True, + 'suffix': 'A', 'param': [('pwm', ch, 'max_i_neg', '-'), ('pid', ch, 'output_min')]}, {'name': 'Max Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, ]} @@ -519,8 +516,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.plot_settings.setMenu(self.plot_menu) - @pyqtSlot(list) - def set_limits_warning(self, limits_zeroed: list): + @pyqtSlot(set) + def set_limits_warning(self, limits_zeroed: set): for channel in limits_zeroed: if len(channel) != 0: break @@ -531,21 +528,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): report_str = "The following output limits are set to zero:\n" for ch in range(2): - had_zeros = False - first = True - for limit in "max_i_pos", "max_v": - if limit in limits_zeroed[ch]: - if not first: - report_str += ", " - if not had_zeros: - report_str += f"Channel {ch}: " - had_zeros = True - report_str += '"Max Absolute Current"' if limit == "max_i_pos" else '"Max Absolute Voltage"' - first = False - if had_zeros: - report_str += '\n' + zeroed = False + if 'max_i_pos' in limits_zeroed[ch] and 'max_i_neg' in limits_zeroed[ch]: + report_str += "Max Current, Min Current" + zeroed = True + if 'max_v' in limits_zeroed[ch]: + report_str += ", " if zeroed else "" + report_str += "Max Absolute Voltage" + zeroed = True + if zeroed: + report_str += f" for Channel {ch}\n" - report_str += "\nThere will be no overall output on channels with zeroed limits." + report_str += "\nThese limit(s) are restricting the channel(s) from producing current." pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") icon = self.style().standardIcon(pixmapi) @@ -677,8 +671,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if inner_param.name() == 'Control Method' and not data: return for thermostat_param in inner_param.opts["param"]: - if len(thermostat_param) == 4: # To tack on prefixes to the data - set_param_args = (*thermostat_param[:3], f'{thermostat_param[3]}{data}') + if len(thermostat_param) == 4: # The only instance is for negative data + set_param_args = (*thermostat_param[:3], -data) elif inner_param.name() == 'Postfilter Rate': set_param_args = (*thermostat_param, *data) elif inner_param.name() == 'Control Method': @@ -820,17 +814,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @pyqtSlot(list) def update_pwm(self, pwm_data): - channel_zeroed_limits = [[] for i in range(2)] + channel_zeroed_limits = [set() for i in range(2)] for pwm_params in pwm_data: channel = pwm_params["channel"] with QSignalBlocker(self.params[channel]): self.params[channel].child("Output Config", "Limits", "Max Absolute Voltage").setValue(pwm_params["max_v"]["value"]) - self.params[channel].child("Output Config", "Limits", "Max Absolute Current").setValue(pwm_params["max_i_pos"]["value"]) + self.params[channel].child("Output Config", "Limits", "Max Current").setValue(pwm_params["max_i_pos"]["value"]) + self.params[channel].child("Output Config", "Limits", "Min Current").setValue(-pwm_params["max_i_neg"]["value"]) - for limit in "max_i_pos", "max_v": + for limit in "max_i_pos", "max_i_neg", "max_v": if pwm_params[limit]["value"] == 0.0: - channel_zeroed_limits[channel].append(limit) + channel_zeroed_limits[channel].add(limit) self.set_limits_warning(channel_zeroed_limits) From 5af14c26eab06e97b51cf7c1ee1ba539298fafcf Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 10 Oct 2023 16:51:08 +0800 Subject: [PATCH 214/247] Limit test current --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b8dfce1..f175852 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -253,7 +253,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': [('pid', ch, 'kd')]}, {'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': 'Test Current', 'type': 'float', 'value': 1, 'step': 0.1, 'limits': (-3, 3), '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'}, ]}, From 2e8b26eb607b992eeffe8ce75c841f1fa3644d35 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 4 Oct 2023 10:56:12 +0800 Subject: [PATCH 215/247] Show all current values in mA Since the max and min is known as (-3A, 3A) on the thermostat TEC ports, there is no need to use other SI prefix units. --- pytec/tec_qt.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index f175852..4f6019b 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -218,20 +218,20 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Thermostat parameters that are particular to a channel""" THERMOSTAT_PARAMETERS = [[ {'name': 'Temperature', 'type': 'float', 'siPrefix': True, 'suffix': '°C', 'decimals': 6, 'readonly': True}, - {'name': 'Current through TEC', 'type': 'float', 'siPrefix': True, 'suffix': 'A', 'decimals': 4, 'readonly': True}, + {'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, 'param': [('pwm', ch, 'pid')], 'children': [ - {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'triggerOnShow': True, - 'suffix': 'A', 'param': [('pwm', ch, 'i_set')]}, + {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True, + 'decimals': 6, 'suffix': 'mA', 'param': [('pwm', ch, 'i_set')]}, {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, 'suffix': '°C', 'param': [('pid', ch, 'target')]}, ]}, {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, - 'suffix': 'A', 'param': [('pwm', ch, 'max_i_pos'), ('pid', ch, 'output_max')]}, - {'name': 'Min Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 0), 'siPrefix': True, - 'suffix': 'A', 'param': [('pwm', ch, 'max_i_neg', '-'), ('pid', ch, 'output_min')]}, + {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), + 'suffix': 'mA', 'param': [('pwm', ch, 'max_i_pos'), ('pid', ch, 'output_max')]}, + {'name': 'Min Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (-3000, 0), + 'suffix': 'mA', 'param': [('pwm', ch, 'max_i_neg', '-'), ('pid', ch, 'output_min')]}, {'name': 'Max Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, ]} @@ -253,7 +253,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': [('pid', ch, 'kd')]}, {'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, 'limits': (-3, 3), 'siPrefix': True, 'suffix': 'A'}, + {'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'}, {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'prefix': '±', 'suffix': '°C'}, {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, @@ -671,9 +671,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if inner_param.name() == 'Control Method' and not data: return for thermostat_param in inner_param.opts["param"]: - if len(thermostat_param) == 4: # The only instance is for negative data - set_param_args = (*thermostat_param[:3], -data) - elif inner_param.name() == 'Postfilter Rate': + if 'Current' in inner_param.name(): + data /= 1000 # Given in mA + if len(thermostat_param) == 4: + if thermostat_param[3] == '-': + data = -data + thermostat_param = thermostat_param[:3] + + if inner_param.name() == 'Postfilter Rate': set_param_args = (*thermostat_param, *data) elif inner_param.name() == 'Control Method': set_param_args = thermostat_param @@ -716,7 +721,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): case PIDAutotuneState.STATE_OFF: self.autotuners[ch].setParam( param.parent().child('Target Temperature').value(), - param.parent().child('Test Current').value(), + param.parent().child('Test Current').value() / 1000, param.parent().child('Temperature Swing').value(), self.report_refresh_spin.value(), 3) @@ -798,10 +803,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): with QSignalBlocker(self.params[channel]): self.params[channel].child("Output Config", "Control Method").setValue(settings["pid_engaged"]) self.channel_graphs[channel].t_line.setVisible(settings["pid_engaged"]) - self.params[channel].child("Output Config", "Control Method", "Set Current").setValue(settings["i_set"]) + self.params[channel].child("Output Config", "Control Method", "Set Current").setValue(settings["i_set"] * 1000) if settings['temperature'] is not None and settings['tec_i'] is not None: self.params[channel].child("Temperature").setValue(settings['temperature']) - self.params[channel].child("Current through TEC").setValue(settings['tec_i']) + self.params[channel].child("Current through TEC").setValue(settings['tec_i'] * 1000) @pyqtSlot(list) def update_thermistor(self, sh_data): @@ -820,8 +825,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): channel = pwm_params["channel"] with QSignalBlocker(self.params[channel]): self.params[channel].child("Output Config", "Limits", "Max Absolute Voltage").setValue(pwm_params["max_v"]["value"]) - self.params[channel].child("Output Config", "Limits", "Max Current").setValue(pwm_params["max_i_pos"]["value"]) - self.params[channel].child("Output Config", "Limits", "Min Current").setValue(-pwm_params["max_i_neg"]["value"]) + self.params[channel].child("Output Config", "Limits", "Max Current").setValue(pwm_params["max_i_pos"]["value"] * 1000) + self.params[channel].child("Output Config", "Limits", "Min Current").setValue(-pwm_params["max_i_neg"]["value"] * 1000) for limit in "max_i_pos", "max_i_neg", "max_v": if pwm_params[limit]["value"] == 0.0: From 83c14fb2dea06ae313f000b165bcb0dbe16ef93b Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 12 Oct 2023 10:50:56 +0800 Subject: [PATCH 216/247] Sync --- pytec/tec_qt.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 4f6019b..1e477a4 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -72,8 +72,9 @@ registerParameterType('mutex', MutexParameter) class WrappedClient(QObject, Client): connection_error = pyqtSignal() - def __init__(self, parent): - super().__init__(parent) + async def start_session(self, *args, **kwargs): + await super().start_session(*args, **kwargs) + await self._sync_pwm_pid_limits() async def _read_line(self): try: @@ -82,10 +83,24 @@ class WrappedClient(QObject, Client): logging.error("Client connection error, disconnecting", exc_info=True) self.connection_error.emit() - async def _check_zero_limits(self): + async def _sync_pwm_pid_limits(self): pwm_report = await self.get_pwm() pid_report = await self.get_pid() - # TODO: Get pid output_max and max_i_pos synced. Same for min and neg. + for pwm_channel, pid_channel in zip(pwm_report, pid_report): + ch = pwm_channel['channel'] + if (pwm_limit := pwm_channel['max_i_pos']['value']) != (pid_limit := pid_channel['parameters']['output_max']): + # Set the minimum of the 2 + if pwm_limit < pid_limit: + await self.set_param('pid', ch, 'output_max', pwm_limit) + else: + await self.set_param('pwm', ch, 'max_i_pos', pid_limit) + + if (pwm_limit := -pwm_channel['max_i_neg']['value']) != (pid_limit := pid_channel['parameters']['output_min']): + # Set the minimum of the 2 + if pwm_limit < pid_limit: + await self.set_param('pid', ch, 'output_min', pwm_limit) + else: + await self.set_param('pwm', ch, 'max_i_neg', -pid_limit) class ClientWatcher(QObject): From 99b40360c7f1960068db2840211339c051e19741 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 12 Oct 2023 11:57:25 +0800 Subject: [PATCH 217/247] Remove siPrefixes for temperature units --- pytec/tec_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 1e477a4..a29719e 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -232,14 +232,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Thermostat parameters that are particular to a channel""" THERMOSTAT_PARAMETERS = [[ - {'name': 'Temperature', 'type': 'float', 'siPrefix': True, 'suffix': '°C', 'decimals': 6, 'readonly': True}, + {'name': 'Temperature', 'type': 'float', 'suffix': '°C', 'decimals': 6, 'readonly': True}, {'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, 'param': [('pwm', ch, 'pid')], 'children': [ {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True, 'decimals': 6, 'suffix': 'mA', 'param': [('pwm', ch, 'i_set')]}, - {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, + {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'suffix': '°C', 'param': [('pid', ch, 'target')]}, ]}, {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ @@ -252,7 +252,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ]} ]}, {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, + {'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'suffix': '°C', 'param': [('s-h', ch, 't0')]}, {'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω', 'param': [('s-h', ch, 'r0')]}, @@ -267,9 +267,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': [('pid', ch, 'ki')]}, {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': [('pid', ch, 'kd')]}, {'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'siPrefix': True, 'suffix': '°C'}, + {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'suffix': '°C'}, {'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'}, - {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'prefix': '±', 'suffix': '°C'}, + {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'prefix': '±', 'suffix': '°C'}, {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, ]}, From 470ea9edb3ac067b8c1abcc56455e86fb4507eed Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 12 Oct 2023 13:13:12 +0800 Subject: [PATCH 218/247] Put PID output min and max into its own section The critical difference between this and the max_i_pos, max_i_neg pair is that output_max and output_min can have the same sign, meaning that it is possible that PID current can be limited to positive values only. --- pytec/tec_qt.py | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a29719e..d29e10f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -72,10 +72,6 @@ registerParameterType('mutex', MutexParameter) class WrappedClient(QObject, Client): connection_error = pyqtSignal() - async def start_session(self, *args, **kwargs): - await super().start_session(*args, **kwargs) - await self._sync_pwm_pid_limits() - async def _read_line(self): try: return await super()._read_line() @@ -83,25 +79,6 @@ class WrappedClient(QObject, Client): logging.error("Client connection error, disconnecting", exc_info=True) self.connection_error.emit() - async def _sync_pwm_pid_limits(self): - pwm_report = await self.get_pwm() - pid_report = await self.get_pid() - for pwm_channel, pid_channel in zip(pwm_report, pid_report): - ch = pwm_channel['channel'] - if (pwm_limit := pwm_channel['max_i_pos']['value']) != (pid_limit := pid_channel['parameters']['output_max']): - # Set the minimum of the 2 - if pwm_limit < pid_limit: - await self.set_param('pid', ch, 'output_max', pwm_limit) - else: - await self.set_param('pwm', ch, 'max_i_pos', pid_limit) - - if (pwm_limit := -pwm_channel['max_i_neg']['value']) != (pid_limit := pid_channel['parameters']['output_min']): - # Set the minimum of the 2 - if pwm_limit < pid_limit: - await self.set_param('pid', ch, 'output_min', pwm_limit) - else: - await self.set_param('pwm', ch, 'max_i_neg', -pid_limit) - class ClientWatcher(QObject): fan_update = pyqtSignal(dict) @@ -244,9 +221,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ]}, {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), - 'suffix': 'mA', 'param': [('pwm', ch, 'max_i_pos'), ('pid', ch, 'output_max')]}, + 'suffix': 'mA', 'param': [('pwm', ch, 'max_i_pos')]}, {'name': 'Min Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (-3000, 0), - 'suffix': 'mA', 'param': [('pwm', ch, 'max_i_neg', '-'), ('pid', ch, 'output_min')]}, + 'suffix': 'mA', 'param': [('pwm', ch, 'max_i_neg', '-')]}, {'name': 'Max Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, ]} @@ -266,6 +243,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': [('pid', ch, 'kp')]}, {'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': [('pid', ch, 'ki')]}, {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': [('pid', ch, 'kd')]}, + {'name': 'Max Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': [('pid', ch, 'output_max')]}, + {'name': 'Min Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': [('pid', ch, 'output_min')]}, {'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'suffix': '°C'}, {'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'}, @@ -807,6 +786,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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"]) + self.params[channel].child("PID Config", "Max Current Output").setValue(settings["parameters"]["output_max"] * 1000) + self.params[channel].child("PID Config", "Min Current Output").setValue(settings["parameters"]["output_min"] * 1000) self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"]) self.channel_graphs[channel].t_line.setValue(round(settings["target"], 6)) From cb2fa36e68157a4ab6230394be92b245ae2e2190 Mon Sep 17 00:00:00 2001 From: atse Date: Thu, 12 Oct 2023 13:18:16 +0800 Subject: [PATCH 219/247] No need for list now that params are all singular --- pytec/tec_qt.py | 59 +++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index d29e10f..6ff6dce 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -213,38 +213,38 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, - 'param': [('pwm', ch, 'pid')], 'children': [ + 'param': ('pwm', ch, 'pid'), 'children': [ {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True, - 'decimals': 6, 'suffix': 'mA', 'param': [('pwm', ch, 'i_set')]}, + 'decimals': 6, 'suffix': 'mA', 'param': ('pwm', ch, 'i_set')}, {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), - 'suffix': '°C', 'param': [('pid', ch, 'target')]}, + 'suffix': '°C', 'param': ('pid', ch, 'target')}, ]}, {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), - 'suffix': 'mA', 'param': [('pwm', ch, 'max_i_pos')]}, + 'suffix': 'mA', 'param': ('pwm', ch, 'max_i_pos')}, {'name': 'Min Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (-3000, 0), - 'suffix': 'mA', 'param': [('pwm', ch, 'max_i_neg', '-')]}, + 'suffix': 'mA', 'param': ('pwm', ch, 'max_i_neg', '-')}, {'name': 'Max Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, - 'suffix': 'V', 'param': [('pwm', ch, 'max_v')]}, + 'suffix': 'V', 'param': ('pwm', ch, 'max_v')}, ]} ]}, {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), - 'suffix': '°C', 'param': [('s-h', ch, 't0')]}, + 'suffix': '°C', 'param': ('s-h', ch, 't0')}, {'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω', - 'param': [('s-h', ch, 'r0')]}, - {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': [('s-h', ch, 'b')]}, + 'param': ('s-h', ch, 'r0')}, + {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': ('s-h', ch, 'b')}, ]}, {'name': 'Postfilter Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Postfilter Rate', 'type': 'list', 'value': ('rate', 16.67), 'param': [('postfilter', ch)], + {'name': 'Postfilter Rate', 'type': 'list', 'value': ('rate', 16.67), 'param': ('postfilter', ch), 'limits': {'Off': ('off',), '16.67 Hz': ('rate', 16.67), '20 Hz': ('rate', 20.0), '21.25 Hz': ('rate', 21.25), '27 Hz': ('rate', 27.0)}}, ]}, {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': [('pid', ch, 'kp')]}, - {'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': [('pid', ch, 'ki')]}, - {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': [('pid', ch, 'kd')]}, - {'name': 'Max Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': [('pid', ch, 'output_max')]}, - {'name': 'Min Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': [('pid', ch, 'output_min')]}, + {'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': ('pid', ch, 'kp')}, + {'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': ('pid', ch, 'ki')}, + {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': ('pid', ch, 'kd')}, + {'name': 'Max Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_max')}, + {'name': 'Min Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_min')}, {'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'suffix': '°C'}, {'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'}, @@ -664,21 +664,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if inner_param.opts.get("param", None) is not None: if inner_param.name() == 'Control Method' and not data: return - for thermostat_param in inner_param.opts["param"]: - if 'Current' in inner_param.name(): - data /= 1000 # Given in mA - if len(thermostat_param) == 4: - if thermostat_param[3] == '-': - data = -data - thermostat_param = thermostat_param[:3] + thermostat_param = inner_param.opts["param"] - if inner_param.name() == 'Postfilter Rate': - set_param_args = (*thermostat_param, *data) - elif inner_param.name() == 'Control Method': - set_param_args = thermostat_param - else: - set_param_args = (*thermostat_param, data) - await self.client.set_param(*set_param_args) + if 'Current' in inner_param.name(): + data /= 1000 # Given in mA + if len(thermostat_param) == 4: + if thermostat_param[3] == '-': + data = -data + thermostat_param = thermostat_param[:3] + + if inner_param.name() == 'Postfilter Rate': + set_param_args = (*thermostat_param, *data) + elif inner_param.name() == 'Control Method': + set_param_args = thermostat_param + else: + set_param_args = (*thermostat_param, data) + await self.client.set_param(*set_param_args) def _set_param_tree(self): From d60172d7ebdcfd835756ca99a54a63a6a71f7c64 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 16 Oct 2023 11:24:41 +0800 Subject: [PATCH 220/247] Set Limits Warning Fix --- pytec/tec_qt.py | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 6ff6dce..51c5a76 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -510,35 +510,35 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.plot_settings.setMenu(self.plot_menu) - @pyqtSlot(set) - def set_limits_warning(self, limits_zeroed: set): - for channel in limits_zeroed: - if len(channel) != 0: - break - else: - self.limits_warning.setPixmap(QtGui.QPixmap()) - self.limits_warning.setToolTip("") - return + @pyqtSlot(list) + def set_limits_warning(self, limits_zeroed: list): + channel_disabled = [False, False] - report_str = "The following output limits are set to zero:\n" - for ch in range(2): - zeroed = False - if 'max_i_pos' in limits_zeroed[ch] and 'max_i_neg' in limits_zeroed[ch]: - report_str += "Max Current, Min Current" - zeroed = True - if 'max_v' in limits_zeroed[ch]: - report_str += ", " if zeroed else "" - report_str += "Max Absolute Voltage" - zeroed = True - if zeroed: + report_str = "The following output limit(s) are set to zero:\n" + for ch, zeroed_limits in enumerate(limits_zeroed): + if {'max_i_pos', 'max_i_neg'}.issubset(zeroed_limits): + report_str += "Max Cooling Current, Max Heating Current" + channel_disabled[ch] = True + + if 'max_v' in zeroed_limits: + if channel_disabled[ch]: + report_str += ", " + report_str += "Max Voltage Difference" + channel_disabled[ch] = True + + if channel_disabled[ch]: report_str += f" for Channel {ch}\n" report_str += "\nThese limit(s) are restricting the channel(s) from producing current." - pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") - icon = self.style().standardIcon(pixmapi) - self.limits_warning.setPixmap(icon.pixmap(16, 16)) - self.limits_warning.setToolTip(report_str) + if True in channel_disabled: + pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") + icon = self.style().standardIcon(pixmapi) + self.limits_warning.setPixmap(icon.pixmap(16, 16)) + self.limits_warning.setToolTip(report_str) + else: + self.limits_warning.setPixmap(QtGui.QPixmap()) + self.limits_warning.setToolTip(None) @pyqtSlot(int) def set_max_samples(self, samples: int): From bf0cb9ef0515e3ae51c19a0860d60ab6cf7afa9c Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 16 Oct 2023 11:28:13 +0800 Subject: [PATCH 221/247] Improve name --- pytec/tec_qt.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 51c5a76..b6922c3 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -220,11 +220,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 'suffix': '°C', 'param': ('pid', ch, 'target')}, ]}, {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), + {'name': 'Max Cooling Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), 'suffix': 'mA', 'param': ('pwm', ch, 'max_i_pos')}, - {'name': 'Min Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (-3000, 0), - 'suffix': 'mA', 'param': ('pwm', ch, 'max_i_neg', '-')}, - {'name': 'Max Absolute Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, + {'name': 'Max Heating Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), + 'suffix': 'mA', 'param': ('pwm', ch, 'max_i_neg')}, + {'name': 'Max Voltage Difference', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V', 'param': ('pwm', ch, 'max_v')}, ]} ]}, @@ -664,15 +664,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if inner_param.opts.get("param", None) is not None: if inner_param.name() == 'Control Method' and not data: return - thermostat_param = inner_param.opts["param"] - if 'Current' in inner_param.name(): data /= 1000 # Given in mA - if len(thermostat_param) == 4: - if thermostat_param[3] == '-': - data = -data - thermostat_param = thermostat_param[:3] + thermostat_param = inner_param.opts["param"] if inner_param.name() == 'Postfilter Rate': set_param_args = (*thermostat_param, *data) elif inner_param.name() == 'Control Method': @@ -821,9 +816,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for pwm_params in pwm_data: channel = pwm_params["channel"] with QSignalBlocker(self.params[channel]): - self.params[channel].child("Output Config", "Limits", "Max Absolute Voltage").setValue(pwm_params["max_v"]["value"]) - self.params[channel].child("Output Config", "Limits", "Max Current").setValue(pwm_params["max_i_pos"]["value"] * 1000) - self.params[channel].child("Output Config", "Limits", "Min Current").setValue(-pwm_params["max_i_neg"]["value"] * 1000) + self.params[channel].child("Output Config", "Limits", "Max Voltage Difference").setValue(pwm_params["max_v"]["value"]) + self.params[channel].child("Output Config", "Limits", "Max Cooling Current").setValue(pwm_params["max_i_pos"]["value"] * 1000) + self.params[channel].child("Output Config", "Limits", "Max Heating Current").setValue(pwm_params["max_i_neg"]["value"] * 1000) for limit in "max_i_pos", "max_i_neg", "max_v": if pwm_params[limit]["value"] == 0.0: From c56a1678248edeacd7c0fcf9e7f07fe6da06cc11 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 16 Oct 2023 11:45:24 +0800 Subject: [PATCH 222/247] Simplify postfilter stuff --- pytec/tec_qt.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index b6922c3..63876c8 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -236,8 +236,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': ('s-h', ch, 'b')}, ]}, {'name': 'Postfilter Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Postfilter Rate', 'type': 'list', 'value': ('rate', 16.67), 'param': ('postfilter', ch), - 'limits': {'Off': ('off',), '16.67 Hz': ('rate', 16.67), '20 Hz': ('rate', 20.0), '21.25 Hz': ('rate', 21.25), '27 Hz': ('rate', 27.0)}}, + {'name': 'Postfilter Rate', 'type': 'list', 'value': 16.67, 'param': ('postfilter', ch, 'rate'), + 'limits': {'Off': None, '16.67 Hz': 16.67, '20 Hz': 20.0, '21.25 Hz': 21.25, '27 Hz': 27.0}}, ]}, {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': ('pid', ch, 'kp')}, @@ -668,8 +668,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): data /= 1000 # Given in mA thermostat_param = inner_param.opts["param"] - if inner_param.name() == 'Postfilter Rate': - set_param_args = (*thermostat_param, *data) + if inner_param.name() == 'Postfilter Rate' and data == None: + set_param_args = (*thermostat_param[:2], 'off') elif inner_param.name() == 'Control Method': set_param_args = thermostat_param else: @@ -831,10 +831,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for postfilter_params in postfilter_data: channel = postfilter_params["channel"] with QSignalBlocker(self.params[channel]): - if postfilter_params["rate"] == None: - self.params[channel].child("Postfilter Config", "Postfilter Rate").setValue(('off',)) - else: - self.params[channel].child("Postfilter Config", "Postfilter Rate").setValue(('rate', postfilter_params["rate"])) + self.params[channel].child("Postfilter Config", "Postfilter Rate").setValue(postfilter_params["rate"]) async def coro_main(): From d8b1e7e96429537f31be6e1c5497c027be4b7476 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 16 Oct 2023 12:00:16 +0800 Subject: [PATCH 223/247] Control Method simplifcation --- pytec/tec_qt.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 63876c8..f824a4a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -213,7 +213,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, - 'param': ('pwm', ch, 'pid'), 'children': [ + 'activater': ('pwm', ch, 'pid'), 'children': [ {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True, 'decimals': 6, 'suffix': 'mA', 'param': ('pwm', ch, 'i_set')}, {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), @@ -662,19 +662,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for inner_param, change, data in changes: if change == 'value': if inner_param.opts.get("param", None) is not None: - if inner_param.name() == 'Control Method' and not data: - return if 'Current' in inner_param.name(): data /= 1000 # Given in mA thermostat_param = inner_param.opts["param"] if inner_param.name() == 'Postfilter Rate' and data == None: set_param_args = (*thermostat_param[:2], 'off') - elif inner_param.name() == 'Control Method': - set_param_args = thermostat_param else: set_param_args = (*thermostat_param, data) await self.client.set_param(*set_param_args) + if inner_param.opts.get('activater', None) is not None: + if data: + await self.client.set_param(*inner_param.opts['activater']) def _set_param_tree(self): From da794080c6e018d515b38f9369601eb63920706b Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 20 Oct 2023 10:47:08 +0800 Subject: [PATCH 224/247] More general activater --- pytec/tec_qt.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index f824a4a..d8aaf82 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -212,8 +212,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Temperature', 'type': 'float', 'suffix': '°C', 'decimals': 6, 'readonly': True}, {'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ - {'name': 'Control Method', 'type': 'mutex', 'limits': {'Constant Current': False, 'Temperature PID': True}, - 'activater': ('pwm', ch, 'pid'), 'children': [ + {'name': 'Control Method', 'type': 'mutex', 'limits': ['Constant Current', 'Temperature PID'], + 'activaters': [None, ('pwm', ch, 'pid')], 'children': [ {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True, 'decimals': 6, 'suffix': 'mA', 'param': ('pwm', ch, 'i_set')}, {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), @@ -671,9 +671,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: set_param_args = (*thermostat_param, data) await self.client.set_param(*set_param_args) - if inner_param.opts.get('activater', None) is not None: - if data: - await self.client.set_param(*inner_param.opts['activater']) + if inner_param.opts.get('activaters', None) is not None: + activater = inner_param.opts['activaters'][inner_param.opts['limits'].index(data)] + if activater is not None: + await self.client.set_param(*activater) def _set_param_tree(self): @@ -792,7 +793,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): channel = settings["channel"] self.channel_graphs[channel].plot_append(settings) with QSignalBlocker(self.params[channel]): - self.params[channel].child("Output Config", "Control Method").setValue(settings["pid_engaged"]) + self.params[channel].child("Output Config", "Control Method").setValue("Temperature PID" if settings["pid_engaged"] else "Constant Current") self.channel_graphs[channel].t_line.setVisible(settings["pid_engaged"]) self.params[channel].child("Output Config", "Control Method", "Set Current").setValue(settings["i_set"] * 1000) if settings['temperature'] is not None and settings['tec_i'] is not None: From 91a2cfb73e3f01545e7a9edaeefbfd7ee5193481 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 20 Oct 2023 10:49:04 +0800 Subject: [PATCH 225/247] Comment --- pytec/tec_qt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index d8aaf82..72a3227 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -659,6 +659,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @asyncSlot(object, object) async def send_command(self, param, changes): + """Translates parameter tree changes into thermostat set_param calls""" + for inner_param, change, data in changes: if change == 'value': if inner_param.opts.get("param", None) is not None: From 8cd1a70f501c8ae7e0c248e96307f66f899924ae Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 20 Oct 2023 10:51:34 +0800 Subject: [PATCH 226/247] variable name change --- pytec/tec_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 72a3227..9c27950 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -511,11 +511,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.plot_settings.setMenu(self.plot_menu) @pyqtSlot(list) - def set_limits_warning(self, limits_zeroed: list): + def set_limits_warning(self, channels_zeroed_limits: list): channel_disabled = [False, False] report_str = "The following output limit(s) are set to zero:\n" - for ch, zeroed_limits in enumerate(limits_zeroed): + for ch, zeroed_limits in enumerate(channels_zeroed_limits): if {'max_i_pos', 'max_i_neg'}.issubset(zeroed_limits): report_str += "Max Cooling Current, Max Heating Current" channel_disabled[ch] = True @@ -813,7 +813,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @pyqtSlot(list) def update_pwm(self, pwm_data): - channel_zeroed_limits = [set() for i in range(2)] + channels_zeroed_limits = [set() for i in range(2)] for pwm_params in pwm_data: channel = pwm_params["channel"] @@ -824,9 +824,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for limit in "max_i_pos", "max_i_neg", "max_v": if pwm_params[limit]["value"] == 0.0: - channel_zeroed_limits[channel].add(limit) + channels_zeroed_limits[channel].add(limit) - self.set_limits_warning(channel_zeroed_limits) + self.set_limits_warning(channels_zeroed_limits) @pyqtSlot(list) def update_postfilter(self, postfilter_data): From 7c1293e3d2f0f97094837879e8829c6c3fb2d065 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 20 Oct 2023 11:03:30 +0800 Subject: [PATCH 227/247] Number of channels generalisation --- pytec/tec_qt.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 9c27950..d28d97a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -20,6 +20,8 @@ from autotune import PIDAutotune, PIDAutotuneState # pyuic6 -x tec_qt.ui -o ui_tec_qt.py from ui_tec_qt import Ui_MainWindow +"""Number of channels provided by the Thermostat""" +NUM_CHANNELS: int = 2 def get_argparser(): parser = argparse.ArgumentParser(description="ARTIQ master") @@ -254,7 +256,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ]}, {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'}, {'name': 'Load from flash', 'type': 'action', 'tip': 'Load config from flash'} - ] for ch in range(2)] + ] for ch in range(NUM_CHANNELS)] def __init__(self, args): super().__init__() @@ -287,18 +289,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params = [ Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch]) - for ch in range(2) + for ch in range(NUM_CHANNELS) ] self._set_param_tree() self.channel_graphs = [ ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph')) - for ch in range(2) + for ch in range(NUM_CHANNELS) ] self.autotuners = [ PIDAutotune(25) - for _ in range(2) + for _ in range(NUM_CHANNELS) ] self.loading_spinner.hide() @@ -813,7 +815,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @pyqtSlot(list) def update_pwm(self, pwm_data): - channels_zeroed_limits = [set() for i in range(2)] + channels_zeroed_limits = [set() for i in range(NUM_CHANNELS)] for pwm_params in pwm_data: channel = pwm_params["channel"] From 98d491203f6f15f8e5abc1b9c27b1d655983c3b3 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 30 Oct 2023 11:30:55 +0800 Subject: [PATCH 228/247] Add exit option in connection menu --- pytec/tec_qt.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index d28d97a..8ece05b 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -343,6 +343,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connection_menu.addAction(port) self.connection_menu.port = port + self.exit_button = QtWidgets.QPushButton() + self.exit_button.setText("Exit") + self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit) + + exit_action = QtWidgets.QWidgetAction(self.exit_button) + exit_action.setDefaultWidget(self.exit_button) + self.connection_menu.addAction(exit_action) + self.connection_menu.exit_action = exit_action + self.connect_btn.setMenu(self.connection_menu) def _set_up_thermostat_menu(self): From 111742b8090855459679fed299a285ee466f0f30 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 1 Nov 2023 11:30:56 +0800 Subject: [PATCH 229/247] Connect on enter press in the connection details --- pytec/tec_qt.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8ece05b..24058f4 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -324,6 +324,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.host_set_line.setMaxLength(15) self.host_set_line.setClearButtonEnabled(True) + def connect_on_enter_press(): + self.connect_btn.click() + self.connection_menu.hide() + self.host_set_line.returnPressed.connect(connect_on_enter_press) + self.host_set_line.setText("192.168.1.26") self.host_set_line.setPlaceholderText("IP for the Thermostat") @@ -338,6 +343,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.port_set_spin.setMaximum(65535) self.port_set_spin.setValue(23) + def connect_only_if_enter_pressed(): + if not self.port_set_spin.hasFocus(): # Don't connect if the spinbox only lost focus + return; + connect_on_enter_press() + self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed) + port = QtWidgets.QWidgetAction(self.connection_menu) port.setDefaultWidget(self.port_set_spin) self.connection_menu.addAction(port) From d119fd22758d033beb286726d5d6baba4510789f Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 1 Nov 2023 11:48:26 +0800 Subject: [PATCH 230/247] Longer duration tooltip for zero limits warning --- pytec/tec_qt.ui | 6 +++++- pytec/ui_tec_qt.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 326d785..b00c405 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -253,7 +253,11 @@ - + + + 1000000000 + + diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 909c89f..73c1455 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -128,6 +128,7 @@ class Ui_MainWindow(object): self.plot_settings.setObjectName("plot_settings") self.settings_layout.addWidget(self.plot_settings) self.limits_warning = QtWidgets.QLabel(parent=self.bottom_settings_group) + self.limits_warning.setToolTipDuration(1000000000) self.limits_warning.setObjectName("limits_warning") self.settings_layout.addWidget(self.limits_warning) self.background_task_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group) From 0e02803b988e8cfca8cfb4385adb8cdb76225f04 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 3 Nov 2023 10:42:59 +0800 Subject: [PATCH 231/247] Adjust exit text --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 24058f4..857a18f 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -355,7 +355,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.connection_menu.port = port self.exit_button = QtWidgets.QPushButton() - self.exit_button.setText("Exit") + self.exit_button.setText("Exit GUI") self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit) exit_action = QtWidgets.QWidgetAction(self.exit_button) From 355cb8360a252a85b1b7083f11fef7db8d6ad38a Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 6 Nov 2023 11:08:39 +0800 Subject: [PATCH 232/247] =?UTF-8?q?Move=20t=5Fline=20setting=20to=20method?= =?UTF-8?q?,=20fixes=200=C2=B0C=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0 °C fix is an ugly one, ideally we should only update the label when visibility returns. --- pytec/tec_qt.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 857a18f..2b86f6b 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -156,8 +156,8 @@ class ChannelGraphs: self._i_plot = LiveLinePlot(name='Measured') self._iset_plot = LiveLinePlot(name='Set', pen=pg.mkPen('r')) - self.t_line = self._t_widget.getPlotItem().addLine(label='{value} °C') - self.t_line.setVisible(False) + self._t_line = self._t_widget.getPlotItem().addLine(label='{value} °C') + self._t_line.setVisible(False) for graph in t_widget, i_widget: time_axis = LiveAxis('bottom', text="Time since Thermostat reset", **{Axis.TICK_FORMAT: Axis.DURATION}) @@ -203,6 +203,17 @@ class ChannelGraphs: for connector in self.t_connector, self.i_connector, self.iset_connector: connector.clear() + def set_t_line(self, temp=None, visible=None): + if visible is not None: + self._t_line.setVisible(visible) + if temp is not None: + self._t_line.setValue(temp) + if visible is False: + # PyQtGraph does not update this text when the line + # is not visible, but we need it so that the temperature + # label doesn't display 0 °C despite not being at 0 °C. + self._t_line.label.setText(f"{temp} °C") + class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): @@ -809,7 +820,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params[channel].child("PID Config", "Max Current Output").setValue(settings["parameters"]["output_max"] * 1000) self.params[channel].child("PID Config", "Min Current Output").setValue(settings["parameters"]["output_min"] * 1000) self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"]) - self.channel_graphs[channel].t_line.setValue(round(settings["target"], 6)) + self.channel_graphs[channel].set_t_line(temp=round(settings["target"], 6)) @pyqtSlot(list) def update_report(self, report_data): @@ -818,7 +829,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.channel_graphs[channel].plot_append(settings) with QSignalBlocker(self.params[channel]): self.params[channel].child("Output Config", "Control Method").setValue("Temperature PID" if settings["pid_engaged"] else "Constant Current") - self.channel_graphs[channel].t_line.setVisible(settings["pid_engaged"]) + self.channel_graphs[channel].set_t_line(visible=settings['pid_engaged']) self.params[channel].child("Output Config", "Control Method", "Set Current").setValue(settings["i_set"] * 1000) if settings['temperature'] is not None and settings['tec_i'] is not None: self.params[channel].child("Temperature").setValue(settings['temperature']) From 938e3bd23fecc623b1b61504ff4147f52add6f8f Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 6 Nov 2023 11:42:56 +0800 Subject: [PATCH 233/247] i_set in front of measured current --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2b86f6b..8e188d4 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -179,8 +179,8 @@ class ChannelGraphs: i_widget.addLegend(brush=(50, 50, 200, 150)) t_widget.addItem(self._t_plot) - i_widget.addItem(self._iset_plot) i_widget.addItem(self._i_plot) + i_widget.addItem(self._iset_plot) self.t_connector = DataConnector(self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES) self.i_connector = DataConnector(self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES) From 3f7d8fdbf3fec39dcdaa0d0f9dd277c74032974d Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 17 Nov 2023 10:45:01 +0800 Subject: [PATCH 234/247] Fix unicode --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 8e188d4..152b5b7 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -506,7 +506,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): c = {self.hw_rev_data['settings']['fan_k_c']}
Fan PWM range: - {self.hw_rev_data['settings']['min_fan_pwm']} – {self.hw_rev_data['settings']['max_fan_pwm']} + {self.hw_rev_data['settings']['min_fan_pwm']} \u2013 {self.hw_rev_data['settings']['max_fan_pwm']}
Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz
From 6be23451cb3fd19e3e867a30e2c88c17ad3b218f Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 27 Nov 2023 11:15:24 +0800 Subject: [PATCH 235/247] Comment change --- pytec/tec_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 152b5b7..2a1f253 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -143,7 +143,7 @@ class ClientWatcher(QObject): class ChannelGraphs: - """Manager of the two graphs of a channel, and its elements.""" + """Manager of a channel's two graphs and their elements.""" """The maximum number of sample points to store.""" DEFAULT_MAX_SAMPLES = 1000 From f3e5bb69bfe293f4cc5ab3f31b12a911ee874f31 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 27 Nov 2023 11:51:31 +0800 Subject: [PATCH 236/247] Get rid of the setpoint line out-of-range problem --- pytec/tec_qt.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2a1f253..dad267c 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -158,6 +158,7 @@ class ChannelGraphs: self._t_line = self._t_widget.getPlotItem().addLine(label='{value} °C') self._t_line.setVisible(False) + self._t_setpoint_plot = LiveLinePlot() # Hack for keeping setpoint line in plot range for graph in t_widget, i_widget: time_axis = LiveAxis('bottom', text="Time since Thermostat reset", **{Axis.TICK_FORMAT: Axis.DURATION}) @@ -179,10 +180,12 @@ class ChannelGraphs: i_widget.addLegend(brush=(50, 50, 200, 150)) t_widget.addItem(self._t_plot) + t_widget.addItem(self._t_setpoint_plot) i_widget.addItem(self._i_plot) i_widget.addItem(self._iset_plot) self.t_connector = DataConnector(self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES) + self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1) self.i_connector = DataConnector(self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES) self.iset_connector = DataConnector(self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES) @@ -196,6 +199,10 @@ class ChannelGraphs: if temperature is not None: self.t_connector.cb_append_data_point(temperature, time) + if self._t_line.isVisible(): + self.t_setpoint_connector.cb_append_data_point(self._t_line.value(), time) + else: + self.t_setpoint_connector.cb_append_data_point(temperature, time) self.i_connector.cb_append_data_point(current, time) self.iset_connector.cb_append_data_point(iset, time) From 979736404337654155fff14bee4aa594246f1889 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 24 Jan 2024 15:21:50 +0800 Subject: [PATCH 237/247] anti-aliasing --- pytec/tec_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index dad267c..d3675c8 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -3,6 +3,7 @@ from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot import pyqtgraph.parametertree.parameterTypes as pTypes from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType import pyqtgraph as pg +pg.setConfigOptions(antialias=True) from pglive.sources.data_connector import DataConnector from pglive.kwargs import Axis from pglive.sources.live_plot import LiveLinePlot From d4bf06e2422da0dee955371c8b0c1d359193b5a2 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 27 Nov 2023 11:52:15 +0800 Subject: [PATCH 238/247] Fix setpoint line label to not display old values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setpoint line still displays 0 °C sometimes! --- pytec/tec_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index d3675c8..257d375 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -216,11 +216,11 @@ class ChannelGraphs: self._t_line.setVisible(visible) if temp is not None: self._t_line.setValue(temp) - if visible is False: - # PyQtGraph does not update this text when the line - # is not visible, but we need it so that the temperature - # label doesn't display 0 °C despite not being at 0 °C. - self._t_line.label.setText(f"{temp} °C") + + # PyQtGraph normally does not update this text when the line + # is not visible, so make sure that the temperature label + # gets updated always, and doesn't stay at an old value. + self._t_line.label.setText(f"{temp} °C") class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): From 2deded5c1ebeed2459d436f35a9702274fbf9b80 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 27 Nov 2023 11:52:49 +0800 Subject: [PATCH 239/247] String quotes --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 257d375..a5bec9a 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -154,8 +154,8 @@ class ChannelGraphs: self._i_widget = i_widget self._t_plot = LiveLinePlot() - self._i_plot = LiveLinePlot(name='Measured') - self._iset_plot = LiveLinePlot(name='Set', pen=pg.mkPen('r')) + self._i_plot = LiveLinePlot(name="Measured") + self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen('r')) self._t_line = self._t_widget.getPlotItem().addLine(label='{value} °C') self._t_line.setVisible(False) From 4c89a05e46b5d1bdbb1f29da2d1d433a125cc2d8 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 29 Nov 2023 12:10:14 +0800 Subject: [PATCH 240/247] Update nix repos and use repo qasync and pyqtgraph Now that they are updated, no reason to use our own. --- flake.lock | 8 ++++---- flake.nix | 29 +++-------------------------- 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/flake.lock b/flake.lock index 79fe89d..21877f4 100644 --- a/flake.lock +++ b/flake.lock @@ -18,16 +18,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1691421349, - "narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=", + "lastModified": 1701156937, + "narHash": "sha256-jpMJOFvOTejx211D8z/gz0ErRtQPy6RXxgD2ZB86mso=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "011567f35433879aae5024fc6ec53f2a0568a6c4", + "rev": "7c4c20509c4363195841faa6c911777a134acdf3", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-23.05", + "ref": "nixos-23.11", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 824bf39..d451e84 100644 --- a/flake.nix +++ b/flake.nix @@ -1,7 +1,7 @@ { description = "Firmware for the Sinara 8451 Thermostat"; - inputs.nixpkgs.url = github:NixOS/nixpkgs/nixos-23.05; + inputs.nixpkgs.url = github:NixOS/nixpkgs/nixos-23.11; inputs.mozilla-overlay = { url = github:mozilla/nixpkgs-mozilla; flake = false; }; outputs = { self, nixpkgs, mozilla-overlay }: @@ -56,39 +56,16 @@ dontFixup = true; }; - qasync = pkgs.python3Packages.buildPythonPackage rec { - pname = "qasync"; - version = "0.27.1"; - format = "pyproject"; - src = pkgs.fetchPypi { - inherit pname version; - sha256 = "sha256-jcdo/R7l3hBEx8MF7M8tOdJNh4A+pxGJ1AJPtHX0mF8="; - }; - buildInputs = [ pkgs.python3Packages.poetry-core ]; - propagatedBuildInputs = [ pkgs.python3Packages.pyqt6 ]; - }; - - pyqtgraph = pkgs.python3Packages.buildPythonPackage rec { - pname = "pyqtgraph"; - version = "0.13.3"; - format = "pyproject"; - src = pkgs.fetchPypi { - inherit pname version; - sha256 = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4="; - }; - propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ]; - }; - pglive = pkgs.python3Packages.buildPythonPackage rec { pname = "pglive"; version = "0.7.2"; format = "pyproject"; src = pkgs.fetchPypi { inherit pname version; - sha256 = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; + hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; }; buildInputs = [ pkgs.python3Packages.poetry-core ]; - propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ]; + propagatedBuildInputs = with pkgs.python3Packages; [ pyqtgraph numpy ]; }; thermostat_gui = pkgs.python3Packages.buildPythonPackage { From 4582a8818c99b8216f20679586bd9c7cef4fbe81 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 2 Jan 2024 11:44:01 +0800 Subject: [PATCH 241/247] Temperatures to 4 dp, not 6 sig. fig --- pytec/tec_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a5bec9a..3e3dfc6 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -230,7 +230,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Thermostat parameters that are particular to a channel""" THERMOSTAT_PARAMETERS = [[ - {'name': 'Temperature', 'type': 'float', 'suffix': '°C', 'decimals': 6, 'readonly': True}, + {'name': 'Temperature', 'type': 'float', 'format': '{value:.4f} °C', 'readonly': True}, {'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True}, {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ {'name': 'Control Method', 'type': 'mutex', 'limits': ['Constant Current', 'Temperature PID'], @@ -238,7 +238,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True, 'decimals': 6, 'suffix': 'mA', 'param': ('pwm', ch, 'i_set')}, {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), - 'suffix': '°C', 'param': ('pid', ch, 'target')}, + 'format': '{value:.4f} °C', 'param': ('pid', ch, 'target')}, ]}, {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Max Cooling Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), @@ -251,7 +251,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ]}, {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), - 'suffix': '°C', 'param': ('s-h', ch, 't0')}, + 'format': '{value:.4f} °C', 'param': ('s-h', ch, 't0')}, {'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω', 'param': ('s-h', ch, 'r0')}, {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': ('s-h', ch, 'b')}, @@ -267,9 +267,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Max Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_max')}, {'name': 'Min Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_min')}, {'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'suffix': '°C'}, + {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'format': '{value:.4f} °C'}, {'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'}, - {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'prefix': '±', 'suffix': '°C'}, + {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'prefix': '±', 'format': '{value:.4f} °C'}, {'name': 'Run', 'type': 'action', 'tip': 'Run'}, ]}, ]}, From 3b13881429dc38bb721a736cf4c653c472263d62 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 2 Jan 2024 11:47:35 +0800 Subject: [PATCH 242/247] Move Postfilter Rate setting to Thermostat Config --- pytec/tec_qt.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 3e3dfc6..d5df38b 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -255,8 +255,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω', 'param': ('s-h', ch, 'r0')}, {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': ('s-h', ch, 'b')}, - ]}, - {'name': 'Postfilter Config', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Postfilter Rate', 'type': 'list', 'value': 16.67, 'param': ('postfilter', ch, 'rate'), 'limits': {'Off': None, '16.67 Hz': 16.67, '20 Hz': 20.0, '21.25 Hz': 21.25, '27 Hz': 27.0}}, ]}, @@ -874,7 +872,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for postfilter_params in postfilter_data: channel = postfilter_params["channel"] with QSignalBlocker(self.params[channel]): - self.params[channel].child("Postfilter Config", "Postfilter Rate").setValue(postfilter_params["rate"]) + self.params[channel].child("Thermistor Config", "Postfilter Rate").setValue(postfilter_params["rate"]) async def coro_main(): From 0e5d7ee9fb59b340f704df54a0779d4de8ce4eb6 Mon Sep 17 00:00:00 2001 From: atse Date: Tue, 9 Jan 2024 12:20:58 +0800 Subject: [PATCH 243/247] asyncio.TimeoutError not needed --- pytec/tec_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index d5df38b..ddcacdb 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -78,7 +78,7 @@ class WrappedClient(QObject, Client): 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 + except (OSError, TimeoutError) as e: logging.error("Client connection error, disconnecting", exc_info=True) self.connection_error.emit() @@ -687,7 +687,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: await self.bail() - except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11 + except (OSError, TimeoutError) as e: logging.error(f"Failed communicating to {host}:{port}: {e}") await self.bail() From e0e37cb6d219ed6c5a32f819f72a265c1f6d9e36 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 31 Jan 2024 13:21:25 +0800 Subject: [PATCH 244/247] Don't plot tec_i (for hwrev v2 and below) --- pytec/tec_qt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index ddcacdb..2142a38 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -204,7 +204,8 @@ class ChannelGraphs: self.t_setpoint_connector.cb_append_data_point(self._t_line.value(), time) else: self.t_setpoint_connector.cb_append_data_point(temperature, time) - self.i_connector.cb_append_data_point(current, time) + if current is not None: + self.i_connector.cb_append_data_point(current, time) self.iset_connector.cb_append_data_point(iset, time) def clear(self): From 04017c00089b288ee708b31ab090463c78dda1c3 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 14 Feb 2024 11:31:09 +0800 Subject: [PATCH 245/247] Change confusing PID limit terminology --- pytec/tec_qt.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 2142a38..a14dc98 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -263,8 +263,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): {'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': ('pid', ch, 'kp')}, {'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': ('pid', ch, 'ki')}, {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': ('pid', ch, 'kd')}, - {'name': 'Max Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_max')}, - {'name': 'Min Current Output', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_min')}, + {'name': "PID Output Clamping", 'expanded': True, 'type': 'group', 'children': [ + {'name': 'Minimum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_min')}, + {'name': 'Maximum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_max')}, + ]}, {'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [ {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'format': '{value:.4f} °C'}, {'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'}, @@ -824,8 +826,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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"]) - self.params[channel].child("PID Config", "Max Current Output").setValue(settings["parameters"]["output_max"] * 1000) - self.params[channel].child("PID Config", "Min Current Output").setValue(settings["parameters"]["output_min"] * 1000) + self.params[channel].child("PID Config", "PID Output Clamping", "Minimum").setValue(settings["parameters"]["output_min"] * 1000) + self.params[channel].child("PID Config", "PID Output Clamping", "Maximum").setValue(settings["parameters"]["output_max"] * 1000) self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"]) self.channel_graphs[channel].set_t_line(temp=round(settings["target"], 6)) From aac517239718e6db2e5ffdf6fcbf440ae3109abf Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 14 Feb 2024 13:04:51 +0800 Subject: [PATCH 246/247] More detailed text for load/save on all channels --- pytec/tec_qt.ui | 4 ++-- pytec/ui_tec_qt.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index b00c405..bcfde55 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -498,7 +498,7 @@ - Load all configs + Load all channel configs from flash Restore configuration for all channels from flash @@ -509,7 +509,7 @@ - Save all configs + Save all channel configs to flash Save configuration for all channels to flash diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 73c1455..c7c2a5a 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -249,9 +249,9 @@ class Ui_MainWindow(object): self.actionNetwork_Settings.setToolTip(_translate("MainWindow", "Configure IPv4 address, netmask length, and optional default gateway")) self.actionAbout_Thermostat.setText(_translate("MainWindow", "About Thermostat")) self.actionAbout_Thermostat.setToolTip(_translate("MainWindow", "Show Thermostat hardware revision, and settings related to i")) - self.actionLoad_all_configs.setText(_translate("MainWindow", "Load all configs")) + self.actionLoad_all_configs.setText(_translate("MainWindow", "Load all channel configs from flash")) self.actionLoad_all_configs.setToolTip(_translate("MainWindow", "Restore configuration for all channels from flash")) - self.actionSave_all_configs.setText(_translate("MainWindow", "Save all configs")) + self.actionSave_all_configs.setText(_translate("MainWindow", "Save all channel configs to flash")) self.actionSave_all_configs.setToolTip(_translate("MainWindow", "Save configuration for all channels to flash")) from pglive.sources.live_plot_widget import LivePlotWidget from pyqtgraph.parametertree import ParameterTree From d9fc7a9e2727e2172875a37d34d052c843ab7a8b Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 6 Mar 2024 15:19:56 +0800 Subject: [PATCH 247/247] Put temperature on anyway --- pytec/plot.py | 10 +++++----- pytec/tec_qt.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pytec/plot.py b/pytec/plot.py index 4a1e6da..bcb0c3b 100644 --- a/pytec/plot.py +++ b/pytec/plot.py @@ -30,15 +30,15 @@ class Series: series = { # 'adc': Series(), # 'sens': Series(lambda x: x * 0.0001), - 'temperature': Series(), - # 'i_set': Series(), - 'pid_output': Series(), + # 'temperature': Series(), + 'i_set': Series(), + # 'pid_output': Series(), # 'vref': Series(), # 'dac_value': Series(), # 'dac_feedback': Series(), - # 'i_tec': Series(), + 'i_tec': Series(), 'tec_i': Series(), - 'tec_u_meas': Series(), + # 'tec_u_meas': Series(), # 'interval': Series(), } series_lock = Lock() diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index a14dc98..db1fbc0 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -840,8 +840,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.params[channel].child("Output Config", "Control Method").setValue("Temperature PID" if settings["pid_engaged"] else "Constant Current") self.channel_graphs[channel].set_t_line(visible=settings['pid_engaged']) self.params[channel].child("Output Config", "Control Method", "Set Current").setValue(settings["i_set"] * 1000) - if settings['temperature'] is not None and settings['tec_i'] is not None: + if settings['temperature'] is not None: self.params[channel].child("Temperature").setValue(settings['temperature']) + if settings['tec_i'] is not None: self.params[channel].child("Current through TEC").setValue(settings['tec_i'] * 1000) @pyqtSlot(list)