diff --git a/.gitignore b/.gitignore
index ff69d57..e0a8987 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
target/
result
+*.pyc
\ No newline at end of file
diff --git a/pytec/autotune.py b/pytec/autotune.py
index c7d4dda..da5ede8 100644
--- a/pytec/autotune.py
+++ b/pytec/autotune.py
@@ -67,6 +67,7 @@ class PIDAutotune:
def setReady(self):
self._state = PIDAutotuneState.STATE_READY
+ self._peak_count = 0
def setOff(self):
self._state = PIDAutotuneState.STATE_OFF
diff --git a/pytec/model/pid_autotuner.py b/pytec/model/pid_autotuner.py
new file mode 100644
index 0000000..2a0e08f
--- /dev/null
+++ b/pytec/model/pid_autotuner.py
@@ -0,0 +1,72 @@
+from PyQt6.QtCore import QObject, pyqtSlot
+from qasync import asyncSlot
+from autotune import PIDAutotuneState, PIDAutotune
+
+
+class PIDAutoTuner(QObject):
+ def __init__(self, parent, client, num_of_channel):
+ super().__init__()
+
+ self._client = client
+ self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
+ self.target_temp = [20.0 for _ in range(num_of_channel)]
+ self.test_current = [1.0 for _ in range(num_of_channel)]
+ self.temp_swing = [1.5 for _ in range(num_of_channel)]
+ self.lookback = [3.0 for _ in range(num_of_channel)]
+ self.sampling_interval = [1 / 16.67 for _ in range(num_of_channel)]
+
+ @pyqtSlot(list)
+ def update_sampling_interval(self, interval):
+ self.sampling_interval = interval
+
+ def set_params(self, params_name, ch, val):
+ getattr(self, params_name)[ch] = val
+
+ def get_state(self, ch):
+ return self.autotuners[ch].state()
+
+ def load_params_and_set_ready(self, ch):
+ self.autotuners[ch].setParam(
+ self.target_temp[ch],
+ self.test_current[ch] / 1000,
+ self.temp_swing[ch],
+ 1 / self.sampling_interval[ch],
+ self.lookback[ch],
+ )
+ self.autotuners[ch].setReady()
+
+ async def stop_pid_from_running(self, ch):
+ self.autotuners[ch].setOff()
+ await self._client.set_param("pwm", ch, "i_set", 0)
+
+ @asyncSlot(list)
+ async def tick(self, report):
+ for channel_report in report:
+ # TODO: Skip when PID Autotune or emit error message if NTC is not connected
+ if channel_report["temperature"] is None:
+ continue
+
+ ch = channel_report["channel"]
+ match self.autotuners[ch].state():
+ case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
+ self.autotuners[ch].run(
+ channel_report["temperature"], channel_report["time"]
+ )
+ await self._client.set_param(
+ "pwm", ch, "i_set", self.autotuners[ch].output()
+ )
+ case PIDAutotuneState.STATE_SUCCEEDED:
+ kp, ki, kd = self.autotuners[ch].get_tec_pid()
+ self.autotuners[ch].setOff()
+
+ await self._client.set_param("pid", ch, "kp", kp)
+ await self._client.set_param("pid", ch, "ki", ki)
+ await self._client.set_param("pid", ch, "kd", kd)
+ await self._client.set_param("pwm", ch, "pid")
+
+ await self._client.set_param(
+ "pid", ch, "target", self.target_temp[ch]
+ )
+ case PIDAutotuneState.STATE_FAILED:
+ self.autotuners[ch].setOff()
+ await self._client.set_param("pwm", ch, "i_set", 0)
diff --git a/pytec/model/property.py b/pytec/model/property.py
new file mode 100644
index 0000000..badea1c
--- /dev/null
+++ b/pytec/model/property.py
@@ -0,0 +1,126 @@
+# A Custom Class that allows defining a QObject Property Dynamically
+# Adapted from: https://stackoverflow.com/questions/48425316/how-to-create-pyqt-properties-dynamically
+
+from functools import wraps
+
+from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal
+
+
+class PropertyMeta(type(QObject)):
+ """Lets a class succinctly define Qt properties."""
+
+ def __new__(cls, name, bases, attrs):
+ for key in list(attrs.keys()):
+ attr = attrs[key]
+ if not isinstance(attr, Property):
+ continue
+
+ types = {list: "QVariantList", dict: "QVariantMap"}
+ type_ = types.get(attr.type_, attr.type_)
+
+ notifier = pyqtSignal(type_)
+ attrs[f"{key}_update"] = notifier
+ attrs[key] = PropertyImpl(type_=type_, name=key, notify=notifier)
+
+ return super().__new__(cls, name, bases, attrs)
+
+
+class Property:
+ """Property definition.
+
+ Instances of this class will be replaced with their full
+ implementation by the PropertyMeta metaclass.
+ """
+
+ def __init__(self, type_):
+ self.type_ = type_
+
+
+class PropertyImpl(pyqtProperty):
+ """Property implementation: gets, sets, and notifies of change."""
+
+ def __init__(self, type_, name, notify):
+ super().__init__(type_, self.getter, self.setter, notify=notify)
+ self.name = name
+
+ def getter(self, instance):
+ return getattr(instance, f"_{self.name}")
+
+ def setter(self, instance, value):
+ signal = getattr(instance, f"{self.name}_update")
+
+ if type(value) in {list, dict}:
+ value = make_notified(value, signal)
+
+ setattr(instance, f"_{self.name}", value)
+ signal.emit(value)
+
+
+class MakeNotified:
+ """Adds notifying signals to lists and dictionaries.
+
+ Creates the modified classes just once, on initialization.
+ """
+
+ change_methods = {
+ list: [
+ "__delitem__",
+ "__iadd__",
+ "__imul__",
+ "__setitem__",
+ "append",
+ "extend",
+ "insert",
+ "pop",
+ "remove",
+ "reverse",
+ "sort",
+ ],
+ dict: [
+ "__delitem__",
+ "__ior__",
+ "__setitem__",
+ "clear",
+ "pop",
+ "popitem",
+ "setdefault",
+ "update",
+ ],
+ }
+
+ def __init__(self):
+ if not hasattr(dict, "__ior__"):
+ # Dictionaries don't have | operator in Python < 3.9.
+ self.change_methods[dict].remove("__ior__")
+ self.notified_class = {
+ type_: self.make_notified_class(type_) for type_ in [list, dict]
+ }
+
+ def __call__(self, seq, signal):
+ """Returns a notifying version of the supplied list or dict."""
+ notified_class = self.notified_class[type(seq)]
+ notified_seq = notified_class(seq)
+ notified_seq.signal = signal
+ return notified_seq
+
+ @classmethod
+ def make_notified_class(cls, parent):
+ notified_class = type(f"notified_{parent.__name__}", (parent,), {})
+ for method_name in cls.change_methods[parent]:
+ original = getattr(notified_class, method_name)
+ notified_method = cls.make_notified_method(original, parent)
+ setattr(notified_class, method_name, notified_method)
+ return notified_class
+
+ @staticmethod
+ def make_notified_method(method, parent):
+ @wraps(method)
+ def notified_method(self, *args, **kwargs):
+ result = getattr(parent, method.__name__)(self, *args, **kwargs)
+ self.signal.emit(self)
+ return result
+
+ return notified_method
+
+
+make_notified = MakeNotified()
diff --git a/pytec/model/thermostat_data_model.py b/pytec/model/thermostat_data_model.py
new file mode 100644
index 0000000..d1a47aa
--- /dev/null
+++ b/pytec/model/thermostat_data_model.py
@@ -0,0 +1,138 @@
+from pytec.aioclient import Client
+from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
+from qasync import asyncSlot
+from model.property import Property, PropertyMeta
+import asyncio
+import logging
+
+
+class WrappedClient(QObject, Client):
+ connection_error = pyqtSignal()
+
+ async def _read_line(self):
+ try:
+ return await super()._read_line()
+ except (Exception, TimeoutError, asyncio.exceptions.TimeoutError):
+ logging.error("Client connection error, disconnecting", exc_info=True)
+ self.connection_error.emit()
+
+
+class Thermostat(QObject, metaclass=PropertyMeta):
+ hw_rev = Property(dict)
+ fan = Property(dict)
+ thermistor = Property(list)
+ pid = Property(list)
+ pwm = Property(list)
+ postfilter = Property(list)
+ interval = Property(list)
+ report = Property(list)
+ info_box_trigger = pyqtSignal(str, str)
+
+ def __init__(self, parent, client, update_s):
+ self._update_s = update_s
+ self._client = client
+ self._watch_task = None
+ self._report_mode_task = None
+ self._poll_for_report = True
+ super().__init__(parent)
+
+ async def run(self):
+ self.task = asyncio.create_task(self.update_params())
+ while True:
+ if self.task.done():
+ if self.task.exception() is not None:
+ try:
+ raise self.task.exception()
+ except (
+ Exception,
+ TimeoutError,
+ asyncio.exceptions.TimeoutError,
+ ):
+ logging.error(
+ "Encountered an error while updating parameter tree.",
+ exc_info=True,
+ )
+ _ = self.task.result()
+ self.task = asyncio.create_task(self.update_params())
+ await asyncio.sleep(self._update_s)
+
+ async def get_hw_rev(self):
+ self.hw_rev = await self._client.hw_rev()
+ return self.hw_rev
+
+ async def update_params(self):
+ self.fan = await self._client.get_fan()
+ self.pwm = await self._client.get_pwm()
+ if self._poll_for_report:
+ self.report = await self._client.report()
+ self.interval = [
+ self.report[i]["interval"] for i in range(len(self.report))
+ ]
+ self.pid = await self._client.get_pid()
+ self.thermistor = await self._client.get_steinhart_hart()
+ self.postfilter = await self._client.get_postfilter()
+
+ def connected(self):
+ return self._client.connected
+
+ def connecting(self):
+ return self._client.connecting
+
+ def start_watching(self):
+ self._watch_task = asyncio.create_task(self.run())
+
+ @asyncSlot()
+ async def stop_watching(self):
+ if self._watch_task is not None:
+ await self.set_report_mode(False)
+ self._watch_task.cancel()
+ self._watch_task = None
+ self.task.cancel()
+ self.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()
+
+ async def report_mode(self):
+ async for report in self._client.report_mode():
+ self.report_update.emit(report)
+ self.interval = [
+ self.report[i]["interval"] for i in range(len(self.report))
+ ]
+
+ async def disconnect(self):
+ await self._client.end_session()
+
+ async def set_ipv4(self, ipv4):
+ await self._client.set_param("ipv4", ipv4)
+
+ async def get_ipv4(self):
+ return await self._client.ipv4()
+
+ @asyncSlot()
+ async def save_cfg(self, ch):
+ await self._client.save_config(ch)
+ self.info_box_trigger.emit(
+ "Config loaded", f"Channel {ch} Config has been loaded from flash."
+ )
+
+ @asyncSlot()
+ async def load_cfg(self, ch):
+ await self._client.load_config(ch)
+ self.info_box_trigger.emit(
+ "Config loaded", f"Channel {ch} Config has been loaded from flash."
+ )
+
+ async def dfu(self):
+ await self._client.dfu()
+
+ async def reset(self):
+ await self._client.reset()
+
+ @pyqtSlot(float)
+ def set_update_s(self, update_s):
+ self._update_s = update_s
diff --git a/pytec/pyproject.toml b/pytec/pyproject.toml
index e36fa60..73ce527 100644
--- a/pytec/pyproject.toml
+++ b/pytec/pyproject.toml
@@ -16,3 +16,6 @@ tec_qt = "tec_qt:main"
[tool.setuptools]
packages.find = {}
py-modules = ["aioexample", "autotune", "example", "plot", "tec_qt", "ui_tec_qt", "waitingspinnerwidget"]
+
+[tool.setuptools.package-data]
+"*" = ["*.*"]
diff --git a/pytec/resources/artiq.ico b/pytec/resources/artiq.ico
new file mode 100644
index 0000000..edb222d
Binary files /dev/null and b/pytec/resources/artiq.ico differ
diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py
index 8c69c76..4acd26d 100644
--- a/pytec/tec_qt.py
+++ b/pytec/tec_qt.py
@@ -1,332 +1,165 @@
-from PyQt6 import QtWidgets, QtGui, QtCore
-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 pglive.sources.live_plot_widget import LivePlotWidget
-from pglive.sources.live_axis import LiveAxis
-import sys
-import argparse
-import logging
-import asyncio
-from pytec.aioclient import Client, StoppedConnecting
-import qasync
+from view.zero_limits_warning import zero_limits_warning_view
+from view.net_settings_input_diag import net_settings_input_diag
+from view.thermostat_ctrl_menu import thermostat_ctrl_menu
+from view.conn_menu import conn_menu
+from view.plot_options_menu import plot_options_menu
+from view.live_plot_view import LiveDataPlotter
+from view.ctrl_panel import ctrl_panel
+from view.info_box import info_box
+from model.pid_autotuner import PIDAutoTuner
+from model.thermostat_data_model import WrappedClient, Thermostat
+import json
+from autotune import PIDAutotuneState
from qasync import asyncSlot, asyncClose
-from autotune import PIDAutotune, PIDAutotuneState
+import qasync
+from pytec.aioclient import StoppedConnecting
+import asyncio
+import logging
+import argparse
+from PyQt6 import QtWidgets, QtGui, uic
+from PyQt6.QtCore import QSignalBlocker, pyqtSlot
+import pyqtgraph as pg
+from functools import partial
+import importlib.resources
-# 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
+pg.setConfigOptions(antialias=True)
+
def get_argparser():
- parser = argparse.ArgumentParser(description="ARTIQ master")
+ parser = argparse.ArgumentParser(description="Thermostat Control Panel")
- 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")
+ 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",
+ )
+ parser.add_argument(
+ "-p",
+ "--param_tree",
+ default=importlib.resources.files("view").joinpath("param_tree.json"),
+ help="Param Tree Description JSON File",
+ )
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._report_mode_task = None
- self._poll_for_report = True
- super().__init__(parent)
-
- async def run(self):
- loop = asyncio.get_running_loop()
- while True:
- time = loop.time()
- await self.update_params()
- await asyncio.sleep(self._update_s - (loop.time() - time))
-
- async def update_params(self):
- self.fan_update.emit(await self._client.get_fan())
- self.pwm_update.emit(await self._client.get_pwm())
- if self._poll_for_report:
- self.report_update.emit(await self._client.report())
- self.pid_update.emit(await self._client.get_pid())
- self.thermistor_update.emit(await self._client.get_steinhart_hart())
- self.postfilter_update.emit(await self._client.get_postfilter())
-
- def start_watching(self):
- self._watch_task = asyncio.create_task(self.run())
-
- @pyqtSlot()
- def stop_watching(self):
- if self._watch_task is not None:
- self._watch_task.cancel()
- self._watch_task = None
-
- async def set_report_mode(self, enabled: bool):
- self._poll_for_report = not enabled
- if enabled:
- self._report_mode_task = asyncio.create_task(self.report_mode())
- else:
- self._client.stop_report_mode()
- if self._report_mode_task is not None:
- await self._report_mode_task
- self._report_mode_task = None
-
- async def report_mode(self):
- async for report in self._client.report_mode():
- self.report_update.emit(report)
-
- @pyqtSlot(float)
- def set_update_s(self, update_s):
- self._update_s = update_s
-
-
-class 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)]
+class MainWindow(QtWidgets.QMainWindow):
+ NUM_CHANNELS = 2
def __init__(self, args):
- super().__init__()
+ super(MainWindow, self).__init__()
- self.setupUi(self)
+ ui_file_path = importlib.resources.files("view").joinpath("tec_qt.ui")
+ uic.loadUi(ui_file_path, 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.show()
- self.max_samples = self.DEFAULT_MAX_SAMPLES
-
- self._set_up_connection_menu()
- self._set_up_thermostat_menu()
- self._set_up_plot_menu()
+ self.hw_rev_data = None
+ self.info_box = info_box()
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.thermostat = Thermostat(
+ self, self.client, 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.autotuners = PIDAutoTuner(self, self.client, 2)
- self.channel_graphs = [
- ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph'))
- for ch in range(NUM_CHANNELS)
- ]
+ def get_ctrl_panel_config(args):
+ with open(args.param_tree, "r") as f:
+ return json.load(f)["ctrl_panel"]
- self.autotuners = [
- PIDAutotune(25)
- for _ in range(NUM_CHANNELS)
+ param_tree_sigActivated_handles = [
+ [
+ [["Save to flash"], partial(self.thermostat.save_cfg, ch)],
+ [["Load from flash"], partial(self.thermostat.load_cfg, ch)],
+ [
+ ["PID Config", "PID Auto Tune", "Run"],
+ partial(self.pid_auto_tune_request, ch),
+ ],
+ ]
+ for ch in range(self.NUM_CHANNELS)
]
+ self.thermostat.info_box_trigger.connect(self.info_box.display_info_box)
+
+ self.zero_limits_warning = zero_limits_warning_view(
+ self.style(), self.limits_warning
+ )
+ self.ctrl_panel_view = ctrl_panel(
+ [self.ch0_tree, self.ch1_tree],
+ get_ctrl_panel_config(args),
+ self.send_command,
+ param_tree_sigActivated_handles,
+ )
+ self.ctrl_panel_view.set_zero_limits_warning_sig.connect(
+ self.zero_limits_warning.set_limits_warning
+ )
+
+ self.thermostat.fan_update.connect(self.fan_update)
+ self.thermostat.report_update.connect(self.ctrl_panel_view.update_report)
+ self.thermostat.report_update.connect(self.autotuners.tick)
+ self.thermostat.report_update.connect(self.pid_autotune_handler)
+ self.thermostat.pid_update.connect(self.ctrl_panel_view.update_pid)
+ self.thermostat.pwm_update.connect(self.ctrl_panel_view.update_pwm)
+ self.thermostat.thermistor_update.connect(
+ self.ctrl_panel_view.update_thermistor
+ )
+ self.thermostat.postfilter_update.connect(
+ self.ctrl_panel_view.update_postfilter
+ )
+ self.thermostat.interval_update.connect(
+ self.autotuners.update_sampling_interval
+ )
+ self.report_apply_btn.clicked.connect(
+ lambda: self.thermostat.set_update_s(self.report_refresh_spin.value())
+ )
+
+ self.channel_graphs = LiveDataPlotter(
+ [
+ [getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")]
+ for ch in range(self.NUM_CHANNELS)
+ ]
+ )
+
+ self.thermostat.report_update.connect(self.channel_graphs.update_report)
+ self.thermostat.pid_update.connect(self.channel_graphs.update_pid)
+
+ self.plot_options_menu = plot_options_menu()
+ self.plot_options_menu.clear.triggered.connect(self.clear_graphs)
+ self.plot_options_menu.samples_spinbox.valueChanged.connect(
+ self.channel_graphs.set_max_samples
+ )
+ self.plot_settings.setMenu(self.plot_options_menu)
+
+ self.conn_menu = conn_menu()
+ self.connect_btn.setMenu(self.conn_menu)
+
+ self.thermostat_ctrl_menu = thermostat_ctrl_menu(self.style())
+ self.thermostat_ctrl_menu.fan_set_act.connect(self.fan_set_request)
+ self.thermostat_ctrl_menu.fan_auto_set_act.connect(self.fan_auto_set_request)
+ self.thermostat_ctrl_menu.reset_act.connect(self.reset_request)
+ self.thermostat_ctrl_menu.dfu_act.connect(self.dfu_request)
+ self.thermostat_ctrl_menu.save_cfg_act.connect(self.save_cfg_request)
+ self.thermostat_ctrl_menu.load_cfg_act.connect(self.load_cfg_request)
+ self.thermostat_ctrl_menu.net_cfg_act.connect(self.net_settings_request)
+
+ self.thermostat.hw_rev_update.connect(self.thermostat_ctrl_menu.hw_rev)
+ self.thermostat_settings.setMenu(self.thermostat_ctrl_menu)
self.loading_spinner.hide()
- self.hw_rev_data = None
-
if args.connect:
if args.IP:
self.host_set_line.setText(args.IP)
@@ -334,365 +167,97 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.port_set_spin.setValue(int(args.PORT))
self.connect_btn.click()
- def _set_up_connection_menu(self):
- self.connection_menu = QtWidgets.QMenu()
- self.connection_menu.setTitle('Connection Settings')
-
- 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.connection_menu.addAction(port)
- self.connection_menu.port = port
-
- 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.thermostat_menu.addAction(fan)
- self.thermostat_menu.fan = fan
-
- @asyncSlot(bool)
- async def reset_thermostat(_):
- await self._on_connection_changed(False)
- await self.client.reset()
- await asyncio.sleep(0.1) # Wait for the reset to start
-
- self.connect_btn.click() # Reconnect
-
- self.actionReset.triggered.connect(reset_thermostat)
- self.thermostat_menu.addAction(self.actionReset)
-
- @asyncSlot(bool)
- async def dfu_mode(_):
- await self._on_connection_changed(False)
- await self.client.dfu()
-
- # TODO: add a firmware flashing GUI?
-
- self.actionEnter_DFU_Mode.triggered.connect(dfu_mode)
- self.thermostat_menu.addAction(self.actionEnter_DFU_Mode)
-
- @asyncSlot(bool)
- async def network_settings(_):
- ask_network = QtWidgets.QInputDialog(self)
- ask_network.setWindowTitle("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"""
-
Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}
-
-
-
- Settings:
- Default fan curve:
- 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_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
-
- Fan available: {self.hw_rev_data['settings']['fan_available']}
-
- Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']}
- """
- )
-
- self.actionAbout_Thermostat.triggered.connect(about_thermostat)
- self.thermostat_menu.addAction(self.actionAbout_Thermostat)
-
- self.thermostat_settings.setMenu(self.thermostat_menu)
-
- def _set_up_plot_menu(self):
- self.plot_menu = QtWidgets.QMenu()
- self.plot_menu.setTitle("Plot Settings")
-
- clear = QtGui.QAction("Clear graphs", self.plot_menu)
- clear.triggered.connect(self.clear_graphs)
- self.plot_menu.addAction(clear)
- self.plot_menu.clear = clear
-
- self.samples_spinbox = QtWidgets.QSpinBox()
- self.samples_spinbox.setRange(2, 100000)
- self.samples_spinbox.setSuffix(' samples')
- self.samples_spinbox.setValue(self.max_samples)
- self.samples_spinbox.valueChanged.connect(self.set_max_samples)
-
- limit_samples = QtWidgets.QWidgetAction(self.plot_menu)
- limit_samples.setDefaultWidget(self.samples_spinbox)
- self.plot_menu.addAction(limit_samples)
- self.plot_menu.limit_samples = limit_samples
-
- self.plot_settings.setMenu(self.plot_menu)
-
- @pyqtSlot(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(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
-
- 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."
-
- 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):
- 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 channel_graph in self.channel_graphs:
- channel_graph.clear()
+ self.channel_graphs.clear_graphs()
async def _on_connection_changed(self, result):
self.graph_group.setEnabled(result)
self.report_group.setEnabled(result)
self.thermostat_settings.setEnabled(result)
- self.host_set_line.setEnabled(not result)
- self.port_set_spin.setEnabled(not result)
+ self.conn_menu.host_set_line.setEnabled(not result)
+ self.conn_menu.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.hw_rev_data = await self.thermostat.get_hw_rev()
+ logging.debug(self.hw_rev_data)
+
self._status(self.hw_rev_data)
- self.client_watcher.start_watching()
- # await self.client.set_param("fan", 1)
+ self.thermostat.start_watching()
else:
self.status_lbl.setText("Disconnected")
- self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
- self.fan_pwm_warning.setToolTip("")
+ self.background_task_lbl.setText("Ready.")
+ self.loading_spinner.hide()
+ self.loading_spinner.stop()
+ self.thermostat_ctrl_menu.fan_pwm_warning.setPixmap(QtGui.QPixmap())
+ self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("")
self.clear_graphs()
self.report_box.setChecked(False)
- await self.client_watcher.set_report_mode(False)
- self.client_watcher.stop_watching()
- self.status_lbl.setText("Disconnected")
-
- def _set_fan_pwm_warning(self):
- if self.fan_power_slider.value() != 100:
- pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
- icon = self.style().standardIcon(pixmapi)
- self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
- self.fan_pwm_warning.setToolTip("Throttling the fan (not recommended on this hardware rev)")
- else:
- self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
- self.fan_pwm_warning.setToolTip("")
+ if not Thermostat.connecting or Thermostat.connected:
+ for ch in range(self.NUM_CHANNELS):
+ if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF:
+ await self.autotuners.stop_pid_from_running(ch)
+ await self.thermostat.set_report_mode(False)
+ self.thermostat.stop_watching()
def _status(self, hw_rev_d: dict):
logging.debug(hw_rev_d)
- self.status_lbl.setText(f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}")
- self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"])
+ self.status_lbl.setText(
+ f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}"
+ )
- @pyqtSlot(dict)
- def fan_update(self, fan_settings: dict):
+ @pyqtSlot("QVariantMap")
+ def fan_update(self, fan_settings):
logging.debug(fan_settings)
if fan_settings is None:
return
- with QSignalBlocker(self.fan_power_slider):
- self.fan_power_slider.setValue(fan_settings["fan_pwm"] or 100) # 0 = PWM off = full strength
- with QSignalBlocker(self.fan_auto_box):
- self.fan_auto_box.setChecked(fan_settings["auto_mode"])
+ with QSignalBlocker(self.thermostat_ctrl_menu.fan_power_slider):
+ self.thermostat_ctrl_menu.fan_power_slider.setValue(
+ fan_settings["fan_pwm"] or 100
+ ) # 0 = PWM off = full strength
+ with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
+ self.thermostat_ctrl_menu.fan_auto_box.setChecked(fan_settings["auto_mode"])
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
- self._set_fan_pwm_warning()
-
- @asyncSlot(int)
- async def fan_set(self, value):
- if not self.client.connected():
- return
- if self.fan_auto_box.isChecked():
- with QSignalBlocker(self.fan_auto_box):
- self.fan_auto_box.setChecked(False)
- await self.client.set_fan(value)
- if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
- self._set_fan_pwm_warning()
-
- @asyncSlot(int)
- async def fan_auto_set(self, enabled):
- if not self.client.connected():
- return
- if enabled:
- await self.client.set_fan("auto")
- self.fan_update(await self.client.get_fan())
- else:
- await self.client.set_fan(self.fan_power_slider.value())
+ self.thermostat_ctrl_menu.set_fan_pwm_warning()
@asyncSlot(int)
async def on_report_box_stateChanged(self, enabled):
- await self.client_watcher.set_report_mode(enabled)
+ await self.thermostat.set_report_mode(enabled)
@asyncClose
async def closeEvent(self, event):
- await self.bail()
+ try:
+ await self.bail()
+ except:
+ pass
@asyncSlot()
async def on_connect_btn_clicked(self):
- host, port = self.host_set_line.text(), self.port_set_spin.value()
+ host, port = (
+ self.conn_menu.host_set_line.text(),
+ self.conn_menu.port_set_spin.value(),
+ )
try:
if not (self.client.connecting() or self.client.connected()):
self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop")
- self.host_set_line.setEnabled(False)
- self.port_set_spin.setEnabled(False)
+ self.conn_menu.host_set_line.setEnabled(False)
+ self.conn_menu.port_set_spin.setEnabled(False)
try:
- await self.client.start_session(host=host, port=port, timeout=30)
+ await self.client.start_session(host=host, port=port, timeout=5)
except StoppedConnecting:
return
await self._on_connection_changed(True)
else:
await self.bail()
- except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11
- logging.error(f"Failed communicating to {host}:{port}: {e}")
- await self.bail()
+ # TODO: Remove asyncio.TimeoutError in Python 3.11
+ except (OSError, TimeoutError, asyncio.TimeoutError):
+ try:
+ await self.bail()
+ except ConnectionResetError:
+ pass
@asyncSlot()
async def bail(self):
@@ -702,181 +267,146 @@ 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"""
+ ch = param.channel
for inner_param, change, data in changes:
- if change == 'value':
+ if change == "value":
if inner_param.opts.get("param", None) is not None:
- if 'Current' in inner_param.name():
- data /= 1000 # Given in mA
+ if inner_param.opts.get("suffix", None) == "mA":
+ 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')
+ if thermostat_param[1] == "ch":
+ thermostat_param[1] = ch
+
+ if inner_param.name() == "Postfilter Rate" and data is None:
+ set_param_args = (*thermostat_param[:2], "off")
else:
set_param_args = (*thermostat_param, data)
+ param.child(*param.childPath(inner_param)).setOpts(lock=True)
await self.client.set_param(*set_param_args)
- if inner_param.opts.get('activaters', None) is not None:
- activater = inner_param.opts['activaters'][inner_param.opts['limits'].index(data)]
+ param.child(*param.childPath(inner_param)).setOpts(lock=False)
+
+ if inner_param.opts.get("pid_autotune", None) is not None:
+ auto_tuner_param = inner_param.opts["pid_autotune"][0]
+ if inner_param.opts["pid_autotune"][1] != "ch":
+ ch = inner_param.opts["pid_autotune"][1]
+ self.autotuners.set_params(auto_tuner_param, ch, data)
+
+ 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:
+ if activater[1] == "ch":
+ activater[1] = ch
await self.client.set_param(*activater)
+ @asyncSlot()
+ async def pid_auto_tune_request(self, ch=0):
+ match self.autotuners.get_state(ch):
+ case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED:
+ self.autotuners.load_params_and_set_ready(ch)
- 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)
-
- @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)
-
- @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() / 1000,
- param.parent().child('Temperature Swing').value(),
- self.report_refresh_spin.value(),
- 3)
- self.autotuners[ch].setReady()
- 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.setOpts(title="Run")
- await self.client.set_param('pwm', ch, 'i_set', 0)
- self.client_watcher.report_update.disconnect(self.autotune_tick)
- 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)
+ case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
+ await self.autotuners.stop_pid_from_running(ch)
+ # To Update the UI elements
+ self.pid_autotune_handler([])
@asyncSlot(list)
- async def autotune_tick(self, report):
- for channel_report in report:
- channel = channel_report['channel']
- match self.autotuners[channel].state():
+ async def pid_autotune_handler(self, _):
+ ch_tuning = []
+ for ch in range(self.NUM_CHANNELS):
+ match self.autotuners.get_state(ch):
+ case PIDAutotuneState.STATE_OFF:
+ self.ctrl_panel_view.change_params_title(
+ ch, ("PID Config", "PID Auto Tune", "Run"), "Run"
+ )
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
- self.autotuners[channel].run(channel_report['temperature'], channel_report['time'])
- await self.client.set_param('pwm', channel, 'i_set', self.autotuners[channel].output())
+ self.ctrl_panel_view.change_params_title(
+ ch, ("PID Config", "PID Auto Tune", "Run"), "Stop"
+ )
+ ch_tuning.append(ch)
+
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")
- 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)
- 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))
+ self.info_box.display_info_box(
+ "PID Autotune Success",
+ f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.",
+ )
+ self.info_box.show()
+
case PIDAutotuneState.STATE_FAILED:
- self.autotuners[channel].setOff()
- 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)
- 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))
+ self.info_box.display_info_box(
+ "PID Autotune Failed", f"Channel {ch} PID Autotune is failed."
+ )
+ self.info_box.show()
- @pyqtSlot(list)
- def update_pid(self, pid_settings):
- for settings in pid_settings:
- channel = settings["channel"]
- with QSignalBlocker(self.params[channel]):
- self.params[channel].child("PID Config", "Kp").setValue(settings["parameters"]["kp"])
- self.params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"])
- self.params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"])
- 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))
+ if len(ch_tuning) == 0:
+ self.background_task_lbl.setText("Ready.")
+ self.loading_spinner.hide()
+ self.loading_spinner.stop()
+ else:
+ self.background_task_lbl.setText(
+ "Autotuning channel {ch}...".format(ch=ch_tuning)
+ )
+ self.loading_spinner.start()
+ self.loading_spinner.show()
- @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("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:
- 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)
+ @asyncSlot(int)
+ async def fan_set_request(self, value):
+ if not self.client.connected():
+ return
+ if self.thermostat_ctrl_menu.fan_auto_box.isChecked():
+ with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
+ self.thermostat_ctrl_menu.fan_auto_box.setChecked(False)
+ await self.client.set_fan(value)
+ if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
+ self.thermostat_ctrl_menu.set_fan_pwm_warning()
- @pyqtSlot(list)
- def update_thermistor(self, sh_data):
- for sh_param in sh_data:
- channel = sh_param["channel"]
- with QSignalBlocker(self.params[channel]):
- self.params[channel].child("Thermistor Config", "T₀").setValue(sh_param["params"]["t0"] - 273.15)
- self.params[channel].child("Thermistor Config", "R₀").setValue(sh_param["params"]["r0"])
- self.params[channel].child("Thermistor Config", "B").setValue(sh_param["params"]["b"])
+ @asyncSlot(int)
+ async def fan_auto_set_request(self, enabled):
+ if not self.client.connected():
+ return
+ if enabled:
+ await self.client.set_fan("auto")
+ self.fan_update(await self.client.get_fan())
+ else:
+ await self.client.set_fan(
+ self.thermostat_ctrl_menu.fan_power_slider.value()
+ )
- @pyqtSlot(list)
- def update_pwm(self, pwm_data):
- channels_zeroed_limits = [set() for i in range(NUM_CHANNELS)]
+ @asyncSlot(int)
+ async def save_cfg_request(self, ch):
+ await self.thermostat.save_cfg(str(ch))
- for pwm_params in pwm_data:
- channel = pwm_params["channel"]
- with QSignalBlocker(self.params[channel]):
- 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)
+ @asyncSlot(int)
+ async def load_cfg_request(self, ch):
+ await self.thermostat.load_cfg(str(ch))
- for limit in "max_i_pos", "max_i_neg", "max_v":
- if pwm_params[limit]["value"] == 0.0:
- channels_zeroed_limits[channel].add(limit)
+ @asyncSlot(bool)
+ async def dfu_request(self, _):
+ await self._on_connection_changed(False)
+ await self.thermostat.dfu()
- self.set_limits_warning(channels_zeroed_limits)
+ @asyncSlot(bool)
+ async def reset_request(self, _):
+ await self._on_connection_changed(False)
+ await self.thermostat.reset()
+ await asyncio.sleep(0.1) # Wait for the reset to start
- @pyqtSlot(list)
- def update_postfilter(self, postfilter_data):
- for postfilter_params in postfilter_data:
- channel = postfilter_params["channel"]
- with QSignalBlocker(self.params[channel]):
- self.params[channel].child("Thermistor Config", "Postfilter Rate").setValue(postfilter_params["rate"])
+ self.connect_btn.click() # Reconnect
+
+ @asyncSlot(bool)
+ async def net_settings_request(self, _):
+ ipv4 = await self.thermostat.get_ipv4()
+ self.net_settings_input_diag = net_settings_input_diag(ipv4["addr"])
+ self.net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request)
+
+ @asyncSlot(str)
+ async def set_net_settings_request(self, ipv4_settings):
+ await self.thermostat.set_ipv4(ipv4_settings)
+ await self.thermostat._client.end_session()
+ await self._on_connection_changed(False)
async def coro_main():
@@ -888,6 +418,9 @@ async def coro_main():
app = QtWidgets.QApplication.instance()
app.aboutToQuit.connect(app_quit_event.set)
+ app.setWindowIcon(
+ QtGui.QIcon(str(importlib.resources.files("resources").joinpath("artiq.ico")))
+ )
main_window = MainWindow(args)
main_window.show()
@@ -899,5 +432,5 @@ def main():
qasync.run(coro_main())
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/pytec/thermostat-icon-640x640.png b/pytec/thermostat-icon-640x640.png
deleted file mode 100644
index 12037a6..0000000
Binary files a/pytec/thermostat-icon-640x640.png and /dev/null differ
diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py
deleted file mode 100644
index c7c2a5a..0000000
--- a/pytec/ui_tec_qt.py
+++ /dev/null
@@ -1,268 +0,0 @@
-# Form implementation generated from reading ui file 'tec_qt.ui'
-#
-# 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.
-
-
-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))
- icon = QtGui.QIcon()
- 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)
- 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 = 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 = 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 = 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 = 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)
- 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.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)
- 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.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)
- 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(240, 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.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")
- self.settings_layout.addWidget(self.thermostat_settings)
- 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)
- 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)
- 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)
- 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_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)
- 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(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)
- 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.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.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)
-
- def retranslateUi(self, MainWindow):
- _translate = QtCore.QCoreApplication.translate
- MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel"))
- self.connect_btn.setText(_translate("MainWindow", "Connect"))
- 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"))
- 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"))
- 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"))
- 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 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 waitingspinnerwidget import QtWaitingSpinner
-
-
-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())
diff --git a/pytec/view/conn_menu.py b/pytec/view/conn_menu.py
new file mode 100644
index 0000000..80da10c
--- /dev/null
+++ b/pytec/view/conn_menu.py
@@ -0,0 +1,56 @@
+from PyQt6 import QtWidgets, QtCore
+
+
+class conn_menu(QtWidgets.QMenu):
+ def __init__(self):
+ super().__init__()
+ self.setTitle("Connection Settings")
+
+ 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.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)
+ host.setDefaultWidget(self.host_set_line)
+ self.addAction(host)
+ self.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)
+ port.setDefaultWidget(self.port_set_spin)
+ self.addAction(port)
+ self.port = port
+
+ 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.addAction(exit_action)
+ self.exit_action = exit_action
diff --git a/pytec/view/ctrl_panel.py b/pytec/view/ctrl_panel.py
new file mode 100644
index 0000000..9ad5a99
--- /dev/null
+++ b/pytec/view/ctrl_panel.py
@@ -0,0 +1,202 @@
+from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
+import pyqtgraph.parametertree.parameterTypes as pTypes
+from pyqtgraph.parametertree import (
+ Parameter,
+ registerParameterType,
+)
+
+
+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 ctrl_panel(QObject):
+ set_zero_limits_warning_sig = pyqtSignal(list)
+
+ def __init__(
+ self,
+ trees_ui,
+ param_tree,
+ sigTreeStateChanged_handle,
+ sigActivated_handles,
+ parent=None,
+ ):
+ super().__init__(parent)
+
+ self.trees_ui = trees_ui
+ self.NUM_CHANNELS = len(trees_ui)
+
+ self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)]
+
+ self.params = [
+ Parameter.create(
+ name=f"Thermostat Channel {ch} Parameters",
+ type="group",
+ value=ch,
+ children=self.THERMOSTAT_PARAMETERS[ch],
+ )
+ for ch in range(self.NUM_CHANNELS)
+ ]
+
+ for i, param in enumerate(self.params):
+ param.channel = i
+
+ for i, tree in enumerate(self.trees_ui):
+ tree.setHeaderHidden(True)
+ tree.setParameters(self.params[i], showTop=False)
+ self.params[i].setValue = self._setValue
+ self.params[i].sigTreeStateChanged.connect(sigTreeStateChanged_handle)
+
+ for handle in sigActivated_handles[i]:
+ self.params[i].child(*handle[0]).sigActivated.connect(handle[1])
+
+ def _setValue(self, value, blockSignal=None):
+ """
+ Implement 'lock' mechanism for Parameter Type
+
+ Modified from the source
+ """
+ try:
+ if blockSignal is not None:
+ self.sigValueChanged.disconnect(blockSignal)
+ value = self._interpretValue(value)
+ if fn.eq(self.opts["value"], value):
+ return value
+
+ if "lock" in self.opts.keys():
+ if self.opts["lock"]:
+ return value
+ self.opts["value"] = value
+ self.sigValueChanged.emit(
+ self, value
+ ) # value might change after signal is received by tree item
+ finally:
+ if blockSignal is not None:
+ self.sigValueChanged.connect(blockSignal)
+
+ return self.opts["value"]
+
+ def change_params_title(self, channel, path, title):
+ self.params[channel].child(*path).setOpts(title=title)
+
+ @pyqtSlot("QVariantList")
+ def update_pid(self, pid_settings):
+ for settings in pid_settings:
+ channel = settings["channel"]
+ with QSignalBlocker(self.params[channel]):
+ self.params[channel].child("PID Config", "Kp").setValue(
+ settings["parameters"]["kp"]
+ )
+ self.params[channel].child("PID Config", "Ki").setValue(
+ settings["parameters"]["ki"]
+ )
+ self.params[channel].child("PID Config", "Kd").setValue(
+ settings["parameters"]["kd"]
+ )
+ 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"])
+
+ @pyqtSlot("QVariantList")
+ def update_report(self, report_data):
+ for settings in report_data:
+ channel = settings["channel"]
+ with QSignalBlocker(self.params[channel]):
+ self.params[channel].child("Output Config", "Control Method").setValue(
+ "Temperature PID" if settings["pid_engaged"] else "Constant Current"
+ )
+ self.params[channel].child(
+ "Output Config", "Control Method", "Set Current"
+ ).setValue(settings["i_set"] * 1000)
+ 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("QVariantList")
+ def update_thermistor(self, sh_data):
+ for sh_param in sh_data:
+ channel = sh_param["channel"]
+ with QSignalBlocker(self.params[channel]):
+ self.params[channel].child("Thermistor Config", "T₀").setValue(
+ sh_param["params"]["t0"] - 273.15
+ )
+ self.params[channel].child("Thermistor Config", "R₀").setValue(
+ sh_param["params"]["r0"]
+ )
+ self.params[channel].child("Thermistor Config", "B").setValue(
+ sh_param["params"]["b"]
+ )
+
+ @pyqtSlot("QVariantList")
+ def update_pwm(self, pwm_data):
+ channels_zeroed_limits = [set() for i in range(self.NUM_CHANNELS)]
+
+ for pwm_params in pwm_data:
+ channel = pwm_params["channel"]
+ with QSignalBlocker(self.params[channel]):
+ 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:
+ channels_zeroed_limits[channel].add(limit)
+ self.set_zero_limits_warning_sig.emit(channels_zeroed_limits)
+
+ @pyqtSlot("QVariantList")
+ def update_postfilter(self, postfilter_data):
+ for postfilter_params in postfilter_data:
+ channel = postfilter_params["channel"]
+ with QSignalBlocker(self.params[channel]):
+ self.params[channel].child(
+ "Thermistor Config", "Postfilter Rate"
+ ).setValue(postfilter_params["rate"])
diff --git a/pytec/view/info_box.py b/pytec/view/info_box.py
new file mode 100644
index 0000000..3d5c491
--- /dev/null
+++ b/pytec/view/info_box.py
@@ -0,0 +1,14 @@
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import pyqtSlot
+
+
+class info_box(QtWidgets.QMessageBox):
+ def __init__(self):
+ super().__init__()
+ self.setIcon(QtWidgets.QMessageBox.Icon.Information)
+
+ @pyqtSlot(str, str)
+ def display_info_box(self, title, text):
+ self.setWindowTitle(title)
+ self.setText(text)
+ self.show()
diff --git a/pytec/view/live_plot_view.py b/pytec/view/live_plot_view.py
new file mode 100644
index 0000000..e9ea57a
--- /dev/null
+++ b/pytec/view/live_plot_view.py
@@ -0,0 +1,168 @@
+from PyQt6.QtCore import QObject, pyqtSlot
+from pglive.sources.data_connector import DataConnector
+from pglive.kwargs import Axis
+from pglive.sources.live_plot import LiveLinePlot
+from pglive.sources.live_axis import LiveAxis
+from collections import deque
+import pyqtgraph as pg
+
+pg.setConfigOptions(antialias=True)
+
+
+class LiveDataPlotter(QObject):
+ def __init__(self, live_plots):
+ super().__init__()
+
+ self.NUM_CHANNELS = len(live_plots)
+ self.graphs = []
+
+ for i, live_plot in enumerate(live_plots):
+ live_plot[0].setTitle(f"Channel {i} Temperature")
+ live_plot[1].setTitle(f"Channel {i} Current")
+ self.graphs.append(_TecGraphs(live_plot[0], live_plot[1]))
+
+ def _config_connector_max_pts(self, connector, samples):
+ connector.max_points = samples
+ connector.x = deque(maxlen=int(connector.max_points))
+ connector.y = deque(maxlen=int(connector.max_points))
+
+ @pyqtSlot(int)
+ def set_max_samples(self, samples: int):
+ for graph in self.graphs:
+ self._config_connector_max_pts(graph.t_connector, samples)
+ self._config_connector_max_pts(graph.i_connector, samples)
+ self._config_connector_max_pts(graph.iset_connector, samples)
+
+ @pyqtSlot()
+ def clear_graphs(self):
+ for graph in self.graphs:
+ graph.clear()
+
+ @pyqtSlot(list)
+ def update_pid(self, pid_settings):
+ for settings in pid_settings:
+ channel = settings["channel"]
+ self.graphs[channel].update_pid(settings)
+
+ @pyqtSlot(list)
+ def update_report(self, report_data):
+ for settings in report_data:
+ channel = settings["channel"]
+ self.graphs[channel].update_report(settings)
+
+
+class _TecGraphs:
+ """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 set_max_sample(self, samples: int):
+ for connector in self.t_connector, self.i_connector, self.iset_connector:
+ connector.max_points(samples)
+
+ 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")
+
+ def set_max_samples(self, samples: int):
+ for graph in self.graphs:
+ graph.t_connector.max_points = samples
+ graph.i_connector.max_points = samples
+ graph.iset_connector.max_points = samples
+
+ def clear_graphs(self):
+ for graph in self.graphs:
+ graph.clear()
+
+ def update_pid(self, pid_settings):
+ self.set_t_line(temp=round(pid_settings["target"], 6))
+
+ def update_report(self, report_data):
+ self.plot_append(report_data)
+ self.set_t_line(visible=report_data["pid_engaged"])
diff --git a/pytec/view/net_settings_input_diag.py b/pytec/view/net_settings_input_diag.py
new file mode 100644
index 0000000..fb1c242
--- /dev/null
+++ b/pytec/view/net_settings_input_diag.py
@@ -0,0 +1,36 @@
+from PyQt6 import QtWidgets
+from PyQt6.QtWidgets import QAbstractButton
+from PyQt6.QtCore import pyqtSignal, pyqtSlot
+
+
+class net_settings_input_diag(QtWidgets.QInputDialog):
+ set_ipv4_act = pyqtSignal(str)
+
+ def __init__(self, current_ipv4_settings):
+ super().__init__()
+ self.setWindowTitle("Network Settings")
+ self.setLabelText(
+ "Set the Thermostat's IPv4 address, netmask and gateway (optional)"
+ )
+ self.setTextValue(current_ipv4_settings)
+ self._new_ipv4 = ""
+
+ @pyqtSlot(str)
+ def set_ipv4(ipv4_settings):
+ self._new_ipv4 = ipv4_settings
+
+ sure = QtWidgets.QMessageBox(self)
+ sure.setWindowTitle("Set network?")
+ sure.setText(
+ f"Setting this as network and disconnecting:
{ipv4_settings}"
+ )
+
+ sure.buttonClicked.connect(self._emit_sig)
+ sure.show()
+
+ self.textValueSelected.connect(set_ipv4)
+ self.show()
+
+ @pyqtSlot(QAbstractButton)
+ def _emit_sig(self, _):
+ self.set_ipv4_act.emit(self._new_ipv4)
diff --git a/pytec/view/param_tree.json b/pytec/view/param_tree.json
new file mode 100644
index 0000000..e495b84
--- /dev/null
+++ b/pytec/view/param_tree.json
@@ -0,0 +1,365 @@
+{
+ "ctrl_panel":[
+ {
+ "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":[
+ null,
+ [
+ "pwm",
+ "ch",
+ "pid"
+ ]
+ ],
+ "children":[
+ {
+ "name":"Set Current",
+ "type":"float",
+ "value":0,
+ "step":100,
+ "limits":[
+ -2000,
+ 2000
+ ],
+ "triggerOnShow":true,
+ "decimals":6,
+ "suffix":"mA",
+ "param":[
+ "pwm",
+ "ch",
+ "i_set"
+ ],
+ "lock":false
+ },
+ {
+ "name":"Set Temperature",
+ "type":"float",
+ "value":25,
+ "step":0.1,
+ "limits":[
+ -273,
+ 300
+ ],
+ "format":"{value:.4f} °C",
+ "param":[
+ "pid",
+ "ch",
+ "target"
+ ],
+ "lock":false
+ }
+ ]
+ },
+ {
+ "name":"Limits",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"Max Cooling Current",
+ "type":"float",
+ "value":0,
+ "step":100,
+ "decimals":6,
+ "limits":[
+ 0,
+ 2000
+ ],
+ "suffix":"mA",
+ "param":[
+ "pwm",
+ "ch",
+ "max_i_pos"
+ ],
+ "lock":false
+ },
+ {
+ "name":"Max Heating Current",
+ "type":"float",
+ "value":0,
+ "step":100,
+ "decimals":6,
+ "limits":[
+ 0,
+ 2000
+ ],
+ "suffix":"mA",
+ "param":[
+ "pwm",
+ "ch",
+ "max_i_neg"
+ ],
+ "lock":false
+ },
+ {
+ "name":"Max Voltage Difference",
+ "type":"float",
+ "value":0,
+ "step":0.1,
+ "limits":[
+ 0,
+ 5
+ ],
+ "siPrefix":true,
+ "suffix":"V",
+ "param":[
+ "pwm",
+ "ch",
+ "max_v"
+ ],
+ "lock":false
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name":"Thermistor Config",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"T₀",
+ "type":"float",
+ "value":25,
+ "step":0.1,
+ "limits":[
+ -100,
+ 100
+ ],
+ "format":"{value:.4f} °C",
+ "param":[
+ "s-h",
+ "ch",
+ "t0"
+ ],
+ "lock":false
+ },
+ {
+ "name":"R₀",
+ "type":"float",
+ "value":10000,
+ "step":1,
+ "siPrefix":true,
+ "suffix":"Ω",
+ "param":[
+ "s-h",
+ "ch",
+ "r0"
+ ],
+ "lock":false
+ },
+ {
+ "name":"B",
+ "type":"float",
+ "value":3950,
+ "step":1,
+ "suffix":"K",
+ "decimals":4,
+ "param":[
+ "s-h",
+ "ch",
+ "b"
+ ],
+ "lock":false
+ },
+ {
+ "name":"Postfilter Rate",
+ "type":"list",
+ "value":16.67,
+ "param":[
+ "postfilter",
+ "ch",
+ "rate"
+ ],
+ "limits":{
+ "Off":null,
+ "16.67 Hz":16.67,
+ "20 Hz":20.0,
+ "21.25 Hz":21.25,
+ "27 Hz":27.0
+ },
+ "lock":false
+ }
+ ]
+ },
+ {
+ "name":"PID Config",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"Kp",
+ "type":"float",
+ "step":0.1,
+ "suffix":"",
+ "param":[
+ "pid",
+ "ch",
+ "kp"
+ ],
+ "lock":false
+ },
+ {
+ "name":"Ki",
+ "type":"float",
+ "step":0.1,
+ "suffix":"Hz",
+ "param":[
+ "pid",
+ "ch",
+ "ki"
+ ],
+ "lock":false
+ },
+ {
+ "name":"Kd",
+ "type":"float",
+ "step":0.1,
+ "suffix":"s",
+ "param":[
+ "pid",
+ "ch",
+ "kd"
+ ],
+ "lock":false
+ },
+ {
+ "name":"PID Output Clamping",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"Minimum",
+ "type":"float",
+ "step":100,
+ "limits":[
+ -2000,
+ 2000
+ ],
+ "decimals":6,
+ "suffix":"mA",
+ "param":[
+ "pid",
+ "ch",
+ "output_min"
+ ],
+ "lock":false
+ },
+ {
+ "name":"Maximum",
+ "type":"float",
+ "step":100,
+ "limits":[
+ -2000,
+ 2000
+ ],
+ "decimals":6,
+ "suffix":"mA",
+ "param":[
+ "pid",
+ "ch",
+ "output_max"
+ ],
+ "lock":false
+ }
+ ]
+ },
+ {
+ "name":"PID Auto Tune",
+ "expanded":false,
+ "type":"group",
+ "children":[
+ {
+ "name":"Target Temperature",
+ "type":"float",
+ "value":20,
+ "step":0.1,
+ "format":"{value:.4f} °C",
+ "pid_autotune":[
+ "target_temp",
+ "ch"
+ ]
+ },
+ {
+ "name":"Test Current",
+ "type":"float",
+ "value":0,
+ "decimals":6,
+ "step":100,
+ "limits":[
+ -2000,
+ 2000
+ ],
+ "suffix":"mA",
+ "pid_autotune":[
+ "test_current",
+ "ch"
+ ]
+ },
+ {
+ "name":"Temperature Swing",
+ "type":"float",
+ "value":1.5,
+ "step":0.1,
+ "prefix":"±",
+ "format":"{value:.4f} °C",
+ "pid_autotune":[
+ "temp_swing",
+ "ch"
+ ]
+ },
+ {
+ "name":"Lookback",
+ "type":"float",
+ "value":3.0,
+ "step":0.1,
+ "format":"{value:.4f} s",
+ "pid_autotune":[
+ "lookback",
+ "ch"
+ ]
+ },
+ {
+ "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"
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/pytec/view/plot_options_menu.py b/pytec/view/plot_options_menu.py
new file mode 100644
index 0000000..427684a
--- /dev/null
+++ b/pytec/view/plot_options_menu.py
@@ -0,0 +1,21 @@
+from PyQt6 import QtWidgets, QtGui
+
+
+class plot_options_menu(QtWidgets.QMenu):
+ def __init__(self, max_samples=1000):
+ super().__init__()
+ self.setTitle("Plot Settings")
+
+ clear = QtGui.QAction("Clear graphs", self)
+ self.addAction(clear)
+ self.clear = clear
+
+ self.samples_spinbox = QtWidgets.QSpinBox()
+ self.samples_spinbox.setRange(2, 100000)
+ self.samples_spinbox.setSuffix(" samples")
+ self.samples_spinbox.setValue(max_samples)
+
+ limit_samples = QtWidgets.QWidgetAction(self)
+ limit_samples.setDefaultWidget(self.samples_spinbox)
+ self.addAction(limit_samples)
+ self.limit_samples = limit_samples
diff --git a/pytec/tec_qt.ui b/pytec/view/tec_qt.ui
similarity index 82%
rename from pytec/tec_qt.ui
rename to pytec/view/tec_qt.ui
index bcfde55..c7d8b35 100644
--- a/pytec/tec_qt.ui
+++ b/pytec/view/tec_qt.ui
@@ -27,7 +27,7 @@
- thermostat-icon-640x640.pngthermostat-icon-640x640.png
+ ../resources/artiq.ico../resources/artiq.ico
@@ -69,14 +69,14 @@
- QFrame::StyledPanel
+ QFrame::Shape::StyledPanel
- QFrame::Raised
+ QFrame::Shadow::Raised
- QLayout::SetDefaultConstraint
+ QLayout::SizeConstraint::SetDefaultConstraint
3
@@ -93,12 +93,6 @@
2
- -
-
-
- -
-
-
-
@@ -111,6 +105,65 @@
-
+ -
+
+
+
+ 0
+ 0
+
+
+
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+ Channel 0
+
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+
+
+
+ Channel 1
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+
+
+
+
@@ -135,10 +188,10 @@
- QFrame::StyledPanel
+ QFrame::Shape::StyledPanel
- QFrame::Raised
+ QFrame::Shadow::Raised
@@ -188,10 +241,10 @@
Connect
- QToolButton::MenuButtonPopup
+ QToolButton::ToolButtonPopupMode::MenuButtonPopup
- Qt::ToolButtonFollowStyle
+ Qt::ToolButtonStyle::ToolButtonFollowStyle
@@ -235,7 +288,7 @@
⚙
- QToolButton::InstantPopup
+ QToolButton::ToolButtonPopupMode::InstantPopup
@@ -248,7 +301,7 @@
📉
- QToolButton::InstantPopup
+ QToolButton::ToolButtonPopupMode::InstantPopup
@@ -272,7 +325,7 @@
-
- Qt::Horizontal
+ Qt::Orientation::Horizontal
@@ -321,7 +374,7 @@
6
- QLayout::SetDefaultConstraint
+ QLayout::SizeConstraint::SetDefaultConstraint
0
@@ -332,7 +385,7 @@
Poll every:
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter
@@ -375,7 +428,7 @@
0.100000000000000
- QAbstractSpinBox::AdaptiveDecimalStepType
+ QAbstractSpinBox::StepType::AdaptiveDecimalStepType
1.000000000000000
@@ -460,7 +513,7 @@
Reset the Thermostat
- QAction::NoRole
+ QAction::MenuRole::NoRole
@@ -471,7 +524,7 @@
Reset thermostat and enter USB device firmware update (DFU) mode
- QAction::NoRole
+ QAction::MenuRole::NoRole
@@ -482,7 +535,7 @@
Configure IPv4 address, netmask length, and optional default gateway
- QAction::NoRole
+ QAction::MenuRole::NoRole
@@ -493,7 +546,7 @@
Show Thermostat hardware revision, and settings related to i
- QAction::NoRole
+ QAction::MenuRole::NoRole
@@ -504,7 +557,7 @@
Restore configuration for all channels from flash
- QAction::NoRole
+ QAction::MenuRole::NoRole
@@ -515,7 +568,7 @@
Save configuration for all channels to flash
- QAction::NoRole
+ QAction::MenuRole::NoRole
@@ -535,7 +588,7 @@
QtWaitingSpinner
QWidget
-
+ view.waitingspinnerwidget
1
diff --git a/pytec/view/thermostat_ctrl_menu.py b/pytec/view/thermostat_ctrl_menu.py
new file mode 100644
index 0000000..e22dfba
--- /dev/null
+++ b/pytec/view/thermostat_ctrl_menu.py
@@ -0,0 +1,145 @@
+from PyQt6 import QtWidgets, QtGui, QtCore
+from PyQt6.QtCore import pyqtSignal, pyqtSlot
+
+
+class thermostat_ctrl_menu(QtWidgets.QMenu):
+ fan_set_act = pyqtSignal(int)
+ fan_auto_set_act = pyqtSignal(int)
+
+ connect_act = pyqtSignal()
+ reset_act = pyqtSignal(bool)
+ dfu_act = pyqtSignal(bool)
+ load_cfg_act = pyqtSignal(int)
+ save_cfg_act = pyqtSignal(int)
+ net_cfg_act = pyqtSignal(bool)
+
+ def __init__(self, style):
+ super().__init__()
+ self._style = style
+ self.setTitle("Thermostat settings")
+
+ self.hw_rev_data = dict()
+
+ 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_act)
+ self.fan_auto_box.stateChanged.connect(self.fan_auto_set_act)
+
+ self.fan_lbl.setToolTip("Adjust the fan")
+ self.fan_lbl.setText("Fan:")
+ self.fan_auto_box.setText("Auto")
+
+ fan = QtWidgets.QWidgetAction(self)
+ fan.setDefaultWidget(self.fan_group)
+ self.addAction(fan)
+ self.fan = fan
+
+ self.actionReset = QtGui.QAction("Reset Thermostat", self)
+ self.actionReset.triggered.connect(self.reset_act)
+ self.addAction(self.actionReset)
+
+ self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self)
+ self.actionEnter_DFU_Mode.triggered.connect(self.dfu_act)
+ self.addAction(self.actionEnter_DFU_Mode)
+
+ self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self)
+ self.actionnet_settings_input_diag.triggered.connect(self.net_cfg_act)
+ self.addAction(self.actionnet_settings_input_diag)
+
+ @pyqtSlot(bool)
+ def load(_):
+ self.load_cfg_act.emit(0)
+ self.load_cfg_act.emit(1)
+ loaded = QtWidgets.QMessageBox(self)
+ loaded.setWindowTitle("Config loaded")
+ loaded.setText("All channel configs have been loaded from flash.")
+ loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
+ loaded.show()
+
+ self.actionLoad_all_configs = QtGui.QAction("Load Config", self)
+ self.actionLoad_all_configs.triggered.connect(load)
+ self.addAction(self.actionLoad_all_configs)
+
+ @pyqtSlot(bool)
+ def save(_):
+ self.save_cfg_act.emit(0)
+ self.save_cfg_act.emit(1)
+ saved = QtWidgets.QMessageBox(self)
+ saved.setWindowTitle("Config saved")
+ saved.setText("All channel configs have been saved to flash.")
+ saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
+ saved.show()
+
+ self.actionSave_all_configs = QtGui.QAction("Save Config", self)
+ self.actionSave_all_configs.triggered.connect(save)
+ self.addAction(self.actionSave_all_configs)
+
+ def about_thermostat():
+ QtWidgets.QMessageBox.about(
+ self,
+ "About Thermostat",
+ f"""
+ Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}
+
+
+
+ Settings:
+ Default fan curve:
+ 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_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
+
+ Fan available: {self.hw_rev_data['settings']['fan_available']}
+
+ Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']}
+ """,
+ )
+
+ self.actionAbout_Thermostat = QtGui.QAction("About Thermostat", self)
+ self.actionAbout_Thermostat.triggered.connect(about_thermostat)
+ self.addAction(self.actionAbout_Thermostat)
+
+ def set_fan_pwm_warning(self):
+ if self.fan_power_slider.value() != 100:
+ pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
+ icon = self._style.standardIcon(pixmapi)
+ self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
+ self.fan_pwm_warning.setToolTip(
+ "Throttling the fan (not recommended on this hardware rev)"
+ )
+ else:
+ self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
+ self.fan_pwm_warning.setToolTip("")
+
+ @pyqtSlot("QVariantMap")
+ def hw_rev(self, hw_rev):
+ self.hw_rev_data = hw_rev
+ self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"])
diff --git a/pytec/waitingspinnerwidget.py b/pytec/view/waitingspinnerwidget.py
similarity index 100%
rename from pytec/waitingspinnerwidget.py
rename to pytec/view/waitingspinnerwidget.py
diff --git a/pytec/view/zero_limits_warning.py b/pytec/view/zero_limits_warning.py
new file mode 100644
index 0000000..574e04d
--- /dev/null
+++ b/pytec/view/zero_limits_warning.py
@@ -0,0 +1,41 @@
+from PyQt6.QtCore import pyqtSlot, QObject
+from PyQt6 import QtWidgets, QtGui
+
+
+class zero_limits_warning_view(QObject):
+ def __init__(self, style, limit_warning):
+ super().__init__()
+ self._lbl = limit_warning
+ self._style = style
+
+ @pyqtSlot("QVariantList")
+ 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(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
+
+ 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."
+ )
+
+ if True in channel_disabled:
+ pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
+ icon = self._style.standardIcon(pixmapi)
+ self._lbl.setPixmap(icon.pixmap(16, 16))
+ self._lbl.setToolTip(report_str)
+ else:
+ self._lbl.setPixmap(QtGui.QPixmap())
+ self._lbl.setToolTip(None)