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 -
waitingspinnerwidget
+
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)