diff --git a/flake.nix b/flake.nix
index ccc5ad8..824bf39 100644
--- a/flake.nix
+++ b/flake.nix
@@ -68,13 +68,37 @@
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";
+ format = "pyproject";
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 +124,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/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/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
diff --git a/pytec/pyproject.toml b/pytec/pyproject.toml
new file mode 100644
index 0000000..e36fa60
--- /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 = ["aioexample", "autotune", "example", "plot", "tec_qt", "ui_tec_qt", "waitingspinnerwidget"]
diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py
index 77b84a7..1054afa 100644
--- a/pytec/pytec/aioclient.py
+++ b/pytec/pytec/aioclient.py
@@ -5,44 +5,53 @@ import logging
class CommandError(Exception):
pass
+class StoppedConnecting(Exception):
+ pass
+
class Client:
def __init__(self):
self._reader = None
self._writer = None
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 TEC with host and port, throws TimeoutError if
- unable to connect. Returns True if not cancelled with disconnect.
+ 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 = aioclient.Client()
- connected = await client.connect()
- if connected:
- return
+ client = Client()
+ try:
+ await client.start_session()
+ except StoppedConnecting:
+ print("Stopped connecting")
"""
- 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)
+ )
+ self.timeout = 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):
+ 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
- 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()
@@ -64,15 +73,19 @@ 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):
+ 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}")
@@ -147,6 +160,14 @@ 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")
+
async def report_mode(self):
"""Start reporting measurement values
@@ -167,9 +188,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 +200,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
@@ -195,23 +223,57 @@ 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 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)
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))
+ if channel == "":
+ await self._read_line() # Read the extra {}
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")
+ 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.end_session()
+
+ 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.end_session()
+
+ async def ipv4(self):
+ """Get the IPv4 settings of the Thermostat"""
+ return await self._command('ipv4')
diff --git a/pytec/setup.py b/pytec/setup.py
index c084cdf..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'],
+ py_modules=['tec_qt', 'ui_tec_qt', 'autotune', 'waitingspinnerwidget'],
)
diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py
index 7989421..8c69c76 100644
--- a/pytec/tec_qt.py
+++ b/pytec/tec_qt.py
@@ -1,65 +1,28 @@
-from PyQt6 import QtWidgets, QtGui
+from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
-from pyqtgraph import PlotWidget
+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 pglive.sources.live_plot_widget import LivePlotWidget
+from pglive.sources.live_axis import LiveAxis
import sys
import argparse
import logging
import asyncio
-from pytec.aioclient import Client
+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
-
-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])]
-
+"""Number of channels provided by the Thermostat"""
+NUM_CHANNELS: int = 2
def get_argparser():
parser = argparse.ArgumentParser(description="ARTIQ master")
@@ -74,16 +37,66 @@ 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()
+
+ 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)
+
+
+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)
report_update = pyqtSignal(list)
pid_update = pyqtSignal(list)
+ thermistor_update = pyqtSignal(list)
+ postfilter_update = pyqtSignal(list)
def __init__(self, parent, client, update_s):
- self.update_s = update_s
- self.client = client
- self.watch_task = None
+ self._update_s = update_s
+ self._client = client
+ self._watch_task = None
+ self._report_mode_task = None
+ self._poll_for_report = True
super().__init__(parent)
async def run(self):
@@ -91,93 +104,517 @@ 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.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())
+ 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())
-
- def is_watching(self):
- return self.watch_task is not None
+ self._watch_task = asyncio.create_task(self.run())
@pyqtSlot()
def stop_watching(self):
- if self.watch_task is not None:
- self.watch_task.cancel()
- self.watch_task = None
+ if self._watch_task is not None:
+ self._watch_task.cancel()
+ self._watch_task = None
+
+ async def set_report_mode(self, enabled: bool):
+ self._poll_for_report = not enabled
+ if enabled:
+ self._report_mode_task = asyncio.create_task(self.report_mode())
+ else:
+ self._client.stop_report_mode()
+ if self._report_mode_task is not None:
+ await self._report_mode_task
+ self._report_mode_task = None
+
+ async def report_mode(self):
+ async for report in self._client.report_mode():
+ self.report_update.emit(report)
@pyqtSlot(float)
def set_update_s(self, update_s):
- self.update_s = update_s
+ self._update_s = update_s
+
+
+class ChannelGraphs:
+ """Manager of a channel's two graphs and their elements."""
+
+ """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="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_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})
+ 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)
+ 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)
+
+ 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)
+ 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)
+ 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):
+ 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)
+
+ # 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):
+
+ """The maximum number of sample points to store."""
+ DEFAULT_MAX_SAMPLES = 1000
+
+ """Thermostat parameters that are particular to a channel"""
+ THERMOSTAT_PARAMETERS = [[
+ {'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'],
+ '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),
+ '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),
+ 'suffix': 'mA', 'param': ('pwm', ch, 'max_i_pos')},
+ {'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')},
+ ]}
+ ]},
+ {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [
+ {'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100),
+ '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')},
+ {'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')},
+ {'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 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'},
+ {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'prefix': '±', 'format': '{value:.4f} °C'},
+ {'name': 'Run', 'type': 'action', 'tip': 'Run'},
+ ]},
+ ]},
+ {'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(NUM_CHANNELS)]
+
def __init__(self, args):
super().__init__()
self.setupUi(self)
- self._set_up_context_menu()
+ 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.fan_power_slider.valueChanged.connect(self.fan_set)
- self.fan_auto_box.stateChanged.connect(self.fan_auto_set)
+ self.max_samples = self.DEFAULT_MAX_SAMPLES
- self._set_param_tree()
+ self._set_up_connection_menu()
+ self._set_up_thermostat_menu()
+ self._set_up_plot_menu()
- self.fan_pwm_recommended = False
-
- self.tec_client = Client()
- self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value())
+ self.client = WrappedClient(self)
+ self.client.connection_error.connect(self.bail)
+ self.client_watcher = ClientWatcher(self, self.client, self.report_refresh_spin.value())
self.client_watcher.fan_update.connect(self.fan_update)
+ self.client_watcher.report_update.connect(self.update_report)
+ self.client_watcher.pid_update.connect(self.update_pid)
+ self.client_watcher.pwm_update.connect(self.update_pwm)
+ self.client_watcher.thermistor_update.connect(self.update_thermistor)
+ self.client_watcher.postfilter_update.connect(self.update_postfilter)
self.report_apply_btn.clicked.connect(
lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value())
)
+ self.params = [
+ Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch])
+ 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(NUM_CHANNELS)
+ ]
+
+ self.autotuners = [
+ PIDAutotune(25)
+ for _ in range(NUM_CHANNELS)
+ ]
+
+ self.loading_spinner.hide()
+
+ self.hw_rev_data = None
+
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()
- def _set_up_context_menu(self):
- self.menu = QtWidgets.QMenu()
- self.menu.setTitle('Thermostat settings')
+ def _set_up_connection_menu(self):
+ self.connection_menu = QtWidgets.QMenu()
+ self.connection_menu.setTitle('Connection Settings')
- port = QtWidgets.QWidgetAction(self.menu)
+ self.host_set_line = QtWidgets.QLineEdit()
+ 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)
+
+ 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")
+
+ host = QtWidgets.QWidgetAction(self.connection_menu)
+ host.setDefaultWidget(self.host_set_line)
+ self.connection_menu.addAction(host)
+ self.connection_menu.host = host
+
+ self.port_set_spin = QtWidgets.QSpinBox()
+ self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0))
+ self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215))
+ self.port_set_spin.setMaximum(65535)
+ self.port_set_spin.setValue(23)
+
+ 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.menu.addAction(port)
- self.menu.port = port
+ self.connection_menu.addAction(port)
+ self.connection_menu.port = port
- fan = QtWidgets.QWidgetAction(self.menu)
+ self.exit_button = QtWidgets.QPushButton()
+ self.exit_button.setText("Exit GUI")
+ 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):
+ self.thermostat_menu = QtWidgets.QMenu()
+ self.thermostat_menu.setTitle('Thermostat settings')
+
+ self.fan_group = QtWidgets.QWidget()
+ self.fan_group.setEnabled(False)
+ self.fan_group.setMinimumSize(QtCore.QSize(40, 0))
+ self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group)
+ self.fan_layout.setSpacing(9)
+ self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group)
+ 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)
+ 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.setRange(1, 100)
+ 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)
+ 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)
+ self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
+ self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
+ 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)
+
+ 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)
- self.menu.addAction(fan)
- self.menu.fan = fan
+ self.thermostat_menu.addAction(fan)
+ self.thermostat_menu.fan = fan
- self.thermostat_settings.setMenu(self.menu)
+ @asyncSlot(bool)
+ async def reset_thermostat(_):
+ await self._on_connection_changed(False)
+ await self.client.reset()
+ await asyncio.sleep(0.1) # Wait for the reset to start
+
+ self.connect_btn.click() # Reconnect
+
+ self.actionReset.triggered.connect(reset_thermostat)
+ self.thermostat_menu.addAction(self.actionReset)
+
+ @asyncSlot(bool)
+ async def dfu_mode(_):
+ await self._on_connection_changed(False)
+ await self.client.dfu()
+
+ # TODO: add a firmware flashing GUI?
+
+ self.actionEnter_DFU_Mode.triggered.connect(dfu_mode)
+ self.thermostat_menu.addAction(self.actionEnter_DFU_Mode)
+
+ @asyncSlot(bool)
+ async def network_settings(_):
+ ask_network = QtWidgets.QInputDialog(self)
+ ask_network.setWindowTitle("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("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)
+ 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)
+
+ @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)
+
+ def about_thermostat():
+ QtWidgets.QMessageBox.about(
+ self,
+ "About Thermostat",
+ f"""
+