diff --git a/flake.nix b/flake.nix index ccc5ad8..824bf39 100644 --- a/flake.nix +++ b/flake.nix @@ -68,13 +68,37 @@ propagatedBuildInputs = [ pkgs.python3Packages.pyqt6 ]; }; + pyqtgraph = pkgs.python3Packages.buildPythonPackage rec { + pname = "pyqtgraph"; + version = "0.13.3"; + format = "pyproject"; + src = pkgs.fetchPypi { + inherit pname version; + sha256 = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4="; + }; + propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ]; + }; + + pglive = pkgs.python3Packages.buildPythonPackage rec { + pname = "pglive"; + version = "0.7.2"; + format = "pyproject"; + src = pkgs.fetchPypi { + inherit pname version; + sha256 = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; + }; + buildInputs = [ pkgs.python3Packages.poetry-core ]; + propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ]; + }; + thermostat_gui = pkgs.python3Packages.buildPythonPackage { pname = "thermostat_gui"; version = "0.0.0"; + format = "pyproject"; src = "${self}/pytec"; nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; - propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync ]); + propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync pglive ]); dontWrapQtApps = true; postFixup = '' @@ -100,7 +124,7 @@ buildInputs = with pkgs; [ rust openocd dfu-util ] ++ (with python3Packages; [ - numpy matplotlib pyqtgraph setuptools pyqt6 qasync + numpy matplotlib pyqtgraph setuptools pyqt6 qasync pglive ]); }; defaultPackage.x86_64-linux = thermostat; diff --git a/pytec/aioexample.py b/pytec/aioexample.py index 2214764..42c02b4 100644 --- a/pytec/aioexample.py +++ b/pytec/aioexample.py @@ -3,7 +3,7 @@ from pytec.aioclient import Client async def main(): tec = Client() - await tec.connect() #(host="192.168.1.26", port=23) + await tec.start_session() #(host="192.168.1.26", port=23) await tec.set_param("s-h", 1, "t0", 20) print(await tec.get_pwm()) print(await tec.get_pid()) diff --git a/pytec/autotune.py b/pytec/autotune.py index bf12432..c7d4dda 100644 --- a/pytec/autotune.py +++ b/pytec/autotune.py @@ -222,20 +222,20 @@ class PIDAutotune: # calculate ultimate gain self._Ku = 4.0 * self._outputstep / \ (self._induced_amplitude * math.pi) - print('Ku: {0}'.format(self._Ku)) + logging.debug('Ku: {0}'.format(self._Ku)) # calculate ultimate period in seconds period1 = self._peak_timestamps[3] - self._peak_timestamps[1] period2 = self._peak_timestamps[4] - self._peak_timestamps[2] self._Pu = 0.5 * (period1 + period2) / 1000.0 - print('Pu: {0}'.format(self._Pu)) + logging.debug('Pu: {0}'.format(self._Pu)) for rule in self._tuning_rules: params = self.get_pid_parameters(rule) - print('rule: {0}'.format(rule)) - print('Kp: {0}'.format(params.Kp)) - print('Ki: {0}'.format(params.Ki)) - print('Kd: {0}'.format(params.Kd)) + logging.debug('rule: {0}'.format(rule)) + logging.debug('Kp: {0}'.format(params.Kp)) + logging.debug('Ki: {0}'.format(params.Ki)) + logging.debug('Kd: {0}'.format(params.Kd)) return True return False diff --git a/pytec/pyproject.toml b/pytec/pyproject.toml new file mode 100644 index 0000000..e36fa60 --- /dev/null +++ b/pytec/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "pytec" +version = "0.0" +authors = [{name = "M-Labs"}] +description = "Control TEC" +urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat" +license = {text = "GPLv3"} + +[project.gui-scripts] +tec_qt = "tec_qt:main" + +[tool.setuptools] +packages.find = {} +py-modules = ["aioexample", "autotune", "example", "plot", "tec_qt", "ui_tec_qt", "waitingspinnerwidget"] diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py index 77b84a7..1054afa 100644 --- a/pytec/pytec/aioclient.py +++ b/pytec/pytec/aioclient.py @@ -5,44 +5,53 @@ import logging class CommandError(Exception): pass +class StoppedConnecting(Exception): + pass + class Client: def __init__(self): self._reader = None self._writer = None self._connecting_task = None self._command_lock = asyncio.Lock() + self._report_mode_on = False + self.timeout = None - async def connect(self, host='192.168.1.26', port=23, timeout=None): - """Connect to the TEC with host and port, throws TimeoutError if - unable to connect. Returns True if not cancelled with disconnect. + async def start_session(self, host='192.168.1.26', port=23, timeout=None): + """Start session to Thermostat at specified host and port. + Throws StoppedConnecting if disconnect was called while connecting. + Throws asyncio.TimeoutError if timeout was exceeded. Example:: - client = aioclient.Client() - connected = await client.connect() - if connected: - return + client = Client() + try: + await client.start_session() + except StoppedConnecting: + print("Stopped connecting") """ - self._connecting_task = asyncio.create_task(asyncio.open_connection(host, port)) + self._connecting_task = asyncio.create_task( + asyncio.wait_for(asyncio.open_connection(host, port), timeout) + ) + self.timeout = timeout try: self._reader, self._writer = await self._connecting_task except asyncio.CancelledError: - return False + raise StoppedConnecting finally: self._connecting_task = None await self._check_zero_limits() - return True - def is_connecting(self): + def connecting(self): """Returns True if client is connecting""" return self._connecting_task is not None - def is_connected(self): + def connected(self): """Returns True if client is connected""" return self._writer is not None - async def disconnect(self): - """Disconnect the client if connected, cancel connection if connecting""" + async def end_session(self): + """End session to Thermostat if connected, cancel connection if connecting""" if self._connecting_task is not None: self._connecting_task.cancel() @@ -64,15 +73,19 @@ class Client: async def _read_line(self): # read 1 line - chunk = await self._reader.readline() + chunk = await asyncio.wait_for(self._reader.readline(), self.timeout) # Only wait for response until timeout return chunk.decode('utf-8', errors='ignore') + async def _read_write(self, command): + self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8')) + await self._writer.drain() + + return await self._read_line() + async def _command(self, *command): async with self._command_lock: - self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8')) - await self._writer.drain() - - line = await self._read_line() + # protect the read-write process from being cancelled midway + line = await asyncio.shield(self._read_write(command)) response = json.loads(line) logging.debug(f"{command}: {response}") @@ -147,6 +160,14 @@ class Client: """ return await self._get_conf("postfilter") + async def get_fan(self): + """Get Thermostat current fan settings""" + return await self._command("fan") + + async def report(self): + """Obtain one-time report on measurement values""" + return await self._command("report") + async def report_mode(self): """Start reporting measurement values @@ -167,9 +188,11 @@ class Client: 'pid_output': 2.067581958092247} """ await self._command("report mode", "on") + self._report_mode_on = True - while True: - line = await self._read_line() + while self._report_mode_on: + async with self._command_lock: + line = await self._read_line() if not line: break try: @@ -177,6 +200,11 @@ class Client: except json.decoder.JSONDecodeError: pass + await self._command("report mode", "off") + + def stop_report_mode(self): + self._report_mode_on = False + async def set_param(self, topic, channel, field="", value=""): """Set configuration parameters @@ -195,23 +223,57 @@ class Client: value = str(value) await self._command(topic, str(channel), field, value) + async def set_fan(self, power="auto"): + """Set fan power""" + await self._command("fan", str(power)) + + async def set_fcurve(self, a=1.0, b=0.0, c=0.0): + """Set fan curve""" + await self._command("fcurve", str(a), str(b), str(c)) + async def power_up(self, channel, target): """Start closed-loop mode""" await self.set_param("pid", channel, "target", value=target) await self.set_param("pwm", channel, "pid") - async def save_config(self): + async def save_config(self, channel=""): """Save current configuration to EEPROM""" - await self._command("save") + await self._command("save", str(channel)) - async def load_config(self): + async def load_config(self, channel=""): """Load current configuration from EEPROM""" - await self._command("load") + await self._command("load", str(channel)) + if channel == "": + await self._read_line() # Read the extra {} async def hw_rev(self): """Get Thermostat hardware revision""" return await self._command("hwrev") - async def fan(self): - """Get Thermostat current fan settings""" - return await self._command("fan") + async def reset(self): + """Reset the Thermostat + + The client is disconnected as the TCP session is terminated. + """ + async with self._command_lock: + self._writer.write("reset\n".encode('utf-8')) + await self._writer.drain() + + await self.end_session() + + async def dfu(self): + """Put the Thermostat in DFU update mode + + The client is disconnected as the Thermostat stops responding to + TCP commands in DFU update mode. The only way to exit it is by + power-cycling. + """ + async with self._command_lock: + self._writer.write("dfu\n".encode('utf-8')) + await self._writer.drain() + + await self.end_session() + + async def ipv4(self): + """Get the IPv4 settings of the Thermostat""" + return await self._command('ipv4') diff --git a/pytec/setup.py b/pytec/setup.py index c084cdf..4ac8f58 100644 --- a/pytec/setup.py +++ b/pytec/setup.py @@ -14,5 +14,5 @@ setup( "tec_qt = tec_qt:main", ] }, - py_modules=['tec_qt', 'ui_tec_qt'], + py_modules=['tec_qt', 'ui_tec_qt', 'autotune', 'waitingspinnerwidget'], ) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py index 7989421..8c69c76 100644 --- a/pytec/tec_qt.py +++ b/pytec/tec_qt.py @@ -1,65 +1,28 @@ -from PyQt6 import QtWidgets, QtGui +from PyQt6 import QtWidgets, QtGui, QtCore from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot -from pyqtgraph import PlotWidget +import pyqtgraph.parametertree.parameterTypes as pTypes from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType import pyqtgraph as pg +pg.setConfigOptions(antialias=True) +from pglive.sources.data_connector import DataConnector +from pglive.kwargs import Axis +from pglive.sources.live_plot import LiveLinePlot +from pglive.sources.live_plot_widget import LivePlotWidget +from pglive.sources.live_axis import LiveAxis import sys import argparse import logging import asyncio -from pytec.aioclient import Client +from pytec.aioclient import Client, StoppedConnecting import qasync from qasync import asyncSlot, asyncClose +from autotune import PIDAutotune, PIDAutotuneState # pyuic6 -x tec_qt.ui -o ui_tec_qt.py from ui_tec_qt import Ui_MainWindow - -class CommandsParameter(Parameter): - def __init__(self, **opts): - super().__init__() - self.opts["commands"] = opts.get("commands", None) - self.opts["payload"] = opts.get("payload", None) - - -ThermostatParams = [[ - {'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, - 'suffix': 'A', 'commands': [f'pwm {ch} i_set {{value}}']}, - {'name': 'Temperature PID', 'type': 'bool', 'value': False, 'commands': [f'pwm {ch} pid'], 'payload': ch, - 'children': [ - {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, - 'suffix': '°C', 'commands': [f'pid {ch} target {{value}}']}, - ]}, - {'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, - 'suffix': 'A', 'commands': [f'pwm {ch} max_i_pos {{value}}', f'pwm {ch} max_i_neg {{value}}', - f'pid {ch} output_min -{{value}}', f'pid {ch} output_max {{value}}']}, - {'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, - 'suffix': 'V', 'commands': [f'pwm {ch} max_v {{value}}']}, - ]}, - {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, - 'suffix': 'C', 'commands': [f's-h {ch} t0 {{value}}']}, - {'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm', - 'commands': [f's-h {ch} r0 {{value}}']}, - {'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1, 'commands': [f's-h {ch} b {{value}}']}, - ]}, - {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} kp {{value}}']}, - {'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} ki {{value}}']}, - {'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} kd {{value}}']}, - {'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [ - {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'}, - {'name': 'Test Current', 'type': 'float', 'value': 1, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'}, - {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'}, - {'name': 'Run', 'type': 'action', 'tip': 'Run'}, - ]}, - ]} -] for ch in range(2)] - -params = [CommandsParameter.create(name='Thermostat Params 0', type='group', children=ThermostatParams[0]), - CommandsParameter.create(name='Thermostat Params 1', type='group', children=ThermostatParams[1])] - +"""Number of channels provided by the Thermostat""" +NUM_CHANNELS: int = 2 def get_argparser(): parser = argparse.ArgumentParser(description="ARTIQ master") @@ -74,16 +37,66 @@ def get_argparser(): return parser +class MutexParameter(pTypes.ListParameter): + """ + Mutually exclusive parameter where only one of its children is visible at a time, list selectable. + + The ordering of the list items determines which children will be visible. + """ + def __init__(self, **opts): + super().__init__(**opts) + + self.sigValueChanged.connect(self.show_chosen_child) + self.sigValueChanged.emit(self, self.opts['value']) + + def _get_param_from_value(self, value): + if isinstance(self.opts['limits'], dict): + values_list = list(self.opts['limits'].values()) + else: + values_list = self.opts['limits'] + + return self.children()[values_list.index(value)] + + @pyqtSlot(object, object) + def show_chosen_child(self, value): + for param in self.children(): + param.hide() + + child_to_show = self._get_param_from_value(value.value()) + child_to_show.show() + + if child_to_show.opts.get('triggerOnShow', None): + child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value()) + + +registerParameterType('mutex', MutexParameter) + + +class WrappedClient(QObject, Client): + connection_error = pyqtSignal() + + async def _read_line(self): + try: + return await super()._read_line() + except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11 + logging.error("Client connection error, disconnecting", exc_info=True) + self.connection_error.emit() + + class ClientWatcher(QObject): fan_update = pyqtSignal(dict) pwm_update = pyqtSignal(list) report_update = pyqtSignal(list) pid_update = pyqtSignal(list) + thermistor_update = pyqtSignal(list) + postfilter_update = pyqtSignal(list) def __init__(self, parent, client, update_s): - self.update_s = update_s - self.client = client - self.watch_task = None + self._update_s = update_s + self._client = client + self._watch_task = None + self._report_mode_task = None + self._poll_for_report = True super().__init__(parent) async def run(self): @@ -91,93 +104,517 @@ class ClientWatcher(QObject): while True: time = loop.time() await self.update_params() - await asyncio.sleep(self.update_s - (loop.time() - time)) + await asyncio.sleep(self._update_s - (loop.time() - time)) async def update_params(self): - self.fan_update.emit(await self.client.fan()) - self.pwm_update.emit(await self.client.get_pwm()) - self.report_update.emit(await self.client._command("report")) - self.pid_update.emit(await self.client.get_pid()) + self.fan_update.emit(await self._client.get_fan()) + self.pwm_update.emit(await self._client.get_pwm()) + if self._poll_for_report: + self.report_update.emit(await self._client.report()) + self.pid_update.emit(await self._client.get_pid()) + self.thermistor_update.emit(await self._client.get_steinhart_hart()) + self.postfilter_update.emit(await self._client.get_postfilter()) def start_watching(self): - self.watch_task = asyncio.create_task(self.run()) - - def is_watching(self): - return self.watch_task is not None + self._watch_task = asyncio.create_task(self.run()) @pyqtSlot() def stop_watching(self): - if self.watch_task is not None: - self.watch_task.cancel() - self.watch_task = None + if self._watch_task is not None: + self._watch_task.cancel() + self._watch_task = None + + async def set_report_mode(self, enabled: bool): + self._poll_for_report = not enabled + if enabled: + self._report_mode_task = asyncio.create_task(self.report_mode()) + else: + self._client.stop_report_mode() + if self._report_mode_task is not None: + await self._report_mode_task + self._report_mode_task = None + + async def report_mode(self): + async for report in self._client.report_mode(): + self.report_update.emit(report) @pyqtSlot(float) def set_update_s(self, update_s): - self.update_s = update_s + self._update_s = update_s + + +class ChannelGraphs: + """Manager of a channel's two graphs and their elements.""" + + """The maximum number of sample points to store.""" + DEFAULT_MAX_SAMPLES = 1000 + + def __init__(self, t_widget, i_widget): + self._t_widget = t_widget + self._i_widget = i_widget + + self._t_plot = LiveLinePlot() + self._i_plot = LiveLinePlot(name="Measured") + self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen('r')) + + self._t_line = self._t_widget.getPlotItem().addLine(label='{value} °C') + self._t_line.setVisible(False) + self._t_setpoint_plot = LiveLinePlot() # Hack for keeping setpoint line in plot range + + for graph in t_widget, i_widget: + time_axis = LiveAxis('bottom', text="Time since Thermostat reset", **{Axis.TICK_FORMAT: Axis.DURATION}) + time_axis.showLabel() + graph.setAxisItems({'bottom': time_axis}) + + graph.add_crosshair(pg.mkPen(color='red', width=1), {'color': 'green'}) + + # Enable linking of axes in the graph widget's context menu + graph.register(graph.getPlotItem().titleLabel.text) # Slight hack getting the title + + temperature_axis = LiveAxis('left', text="Temperature", units="°C") + temperature_axis.showLabel() + t_widget.setAxisItems({'left': temperature_axis}) + + current_axis = LiveAxis('left', text="Current", units="A") + current_axis.showLabel() + i_widget.setAxisItems({'left': current_axis}) + i_widget.addLegend(brush=(50, 50, 200, 150)) + + t_widget.addItem(self._t_plot) + t_widget.addItem(self._t_setpoint_plot) + i_widget.addItem(self._i_plot) + i_widget.addItem(self._iset_plot) + + self.t_connector = DataConnector(self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES) + self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1) + self.i_connector = DataConnector(self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES) + self.iset_connector = DataConnector(self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES) + + self.max_samples = self.DEFAULT_MAX_SAMPLES + + def plot_append(self, report): + temperature = report['temperature'] + current = report['tec_i'] + iset = report['i_set'] + time = report['time'] + + if temperature is not None: + self.t_connector.cb_append_data_point(temperature, time) + if self._t_line.isVisible(): + self.t_setpoint_connector.cb_append_data_point(self._t_line.value(), time) + else: + self.t_setpoint_connector.cb_append_data_point(temperature, time) + if current is not None: + self.i_connector.cb_append_data_point(current, time) + self.iset_connector.cb_append_data_point(iset, time) + + def clear(self): + for connector in self.t_connector, self.i_connector, self.iset_connector: + connector.clear() + + def set_t_line(self, temp=None, visible=None): + if visible is not None: + self._t_line.setVisible(visible) + if temp is not None: + self._t_line.setValue(temp) + + # PyQtGraph normally does not update this text when the line + # is not visible, so make sure that the temperature label + # gets updated always, and doesn't stay at an old value. + self._t_line.label.setText(f"{temp} °C") class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): + + """The maximum number of sample points to store.""" + DEFAULT_MAX_SAMPLES = 1000 + + """Thermostat parameters that are particular to a channel""" + THERMOSTAT_PARAMETERS = [[ + {'name': 'Temperature', 'type': 'float', 'format': '{value:.4f} °C', 'readonly': True}, + {'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True}, + {'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [ + {'name': 'Control Method', 'type': 'mutex', 'limits': ['Constant Current', 'Temperature PID'], + 'activaters': [None, ('pwm', ch, 'pid')], 'children': [ + {'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True, + 'decimals': 6, 'suffix': 'mA', 'param': ('pwm', ch, 'i_set')}, + {'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), + 'format': '{value:.4f} °C', 'param': ('pid', ch, 'target')}, + ]}, + {'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'Max Cooling Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), + 'suffix': 'mA', 'param': ('pwm', ch, 'max_i_pos')}, + {'name': 'Max Heating Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000), + 'suffix': 'mA', 'param': ('pwm', ch, 'max_i_neg')}, + {'name': 'Max Voltage Difference', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, + 'suffix': 'V', 'param': ('pwm', ch, 'max_v')}, + ]} + ]}, + {'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), + 'format': '{value:.4f} °C', 'param': ('s-h', ch, 't0')}, + {'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω', + 'param': ('s-h', ch, 'r0')}, + {'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': ('s-h', ch, 'b')}, + {'name': 'Postfilter Rate', 'type': 'list', 'value': 16.67, 'param': ('postfilter', ch, 'rate'), + 'limits': {'Off': None, '16.67 Hz': 16.67, '20 Hz': 20.0, '21.25 Hz': 21.25, '27 Hz': 27.0}}, + ]}, + {'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': ('pid', ch, 'kp')}, + {'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': ('pid', ch, 'ki')}, + {'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': ('pid', ch, 'kd')}, + {'name': "PID Output Clamping", 'expanded': True, 'type': 'group', 'children': [ + {'name': 'Minimum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_min')}, + {'name': 'Maximum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_max')}, + ]}, + {'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [ + {'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'format': '{value:.4f} °C'}, + {'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'}, + {'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'prefix': '±', 'format': '{value:.4f} °C'}, + {'name': 'Run', 'type': 'action', 'tip': 'Run'}, + ]}, + ]}, + {'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'}, + {'name': 'Load from flash', 'type': 'action', 'tip': 'Load config from flash'} + ] for ch in range(NUM_CHANNELS)] + def __init__(self, args): super().__init__() self.setupUi(self) - self._set_up_context_menu() + self.ch0_t_graph.setTitle("Channel 0 Temperature") + self.ch0_i_graph.setTitle("Channel 0 Current") + self.ch1_t_graph.setTitle("Channel 1 Temperature") + self.ch1_i_graph.setTitle("Channel 1 Current") - self.fan_power_slider.valueChanged.connect(self.fan_set) - self.fan_auto_box.stateChanged.connect(self.fan_auto_set) + self.max_samples = self.DEFAULT_MAX_SAMPLES - self._set_param_tree() + self._set_up_connection_menu() + self._set_up_thermostat_menu() + self._set_up_plot_menu() - self.fan_pwm_recommended = False - - self.tec_client = Client() - self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value()) + self.client = WrappedClient(self) + self.client.connection_error.connect(self.bail) + self.client_watcher = ClientWatcher(self, self.client, self.report_refresh_spin.value()) self.client_watcher.fan_update.connect(self.fan_update) + self.client_watcher.report_update.connect(self.update_report) + self.client_watcher.pid_update.connect(self.update_pid) + self.client_watcher.pwm_update.connect(self.update_pwm) + self.client_watcher.thermistor_update.connect(self.update_thermistor) + self.client_watcher.postfilter_update.connect(self.update_postfilter) self.report_apply_btn.clicked.connect( lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value()) ) + self.params = [ + Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch]) + for ch in range(NUM_CHANNELS) + ] + self._set_param_tree() + + self.channel_graphs = [ + ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph')) + for ch in range(NUM_CHANNELS) + ] + + self.autotuners = [ + PIDAutotune(25) + for _ in range(NUM_CHANNELS) + ] + + self.loading_spinner.hide() + + self.hw_rev_data = None + if args.connect: if args.IP: - self.ip_set_line.setText(args.IP) + self.host_set_line.setText(args.IP) if args.PORT: self.port_set_spin.setValue(int(args.PORT)) self.connect_btn.click() - def _set_up_context_menu(self): - self.menu = QtWidgets.QMenu() - self.menu.setTitle('Thermostat settings') + def _set_up_connection_menu(self): + self.connection_menu = QtWidgets.QMenu() + self.connection_menu.setTitle('Connection Settings') - port = QtWidgets.QWidgetAction(self.menu) + self.host_set_line = QtWidgets.QLineEdit() + self.host_set_line.setMinimumSize(QtCore.QSize(160, 0)) + self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215)) + self.host_set_line.setMaxLength(15) + self.host_set_line.setClearButtonEnabled(True) + + def connect_on_enter_press(): + self.connect_btn.click() + self.connection_menu.hide() + self.host_set_line.returnPressed.connect(connect_on_enter_press) + + self.host_set_line.setText("192.168.1.26") + self.host_set_line.setPlaceholderText("IP for the Thermostat") + + host = QtWidgets.QWidgetAction(self.connection_menu) + host.setDefaultWidget(self.host_set_line) + self.connection_menu.addAction(host) + self.connection_menu.host = host + + self.port_set_spin = QtWidgets.QSpinBox() + self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0)) + self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215)) + self.port_set_spin.setMaximum(65535) + self.port_set_spin.setValue(23) + + def connect_only_if_enter_pressed(): + if not self.port_set_spin.hasFocus(): # Don't connect if the spinbox only lost focus + return; + connect_on_enter_press() + self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed) + + port = QtWidgets.QWidgetAction(self.connection_menu) port.setDefaultWidget(self.port_set_spin) - self.menu.addAction(port) - self.menu.port = port + self.connection_menu.addAction(port) + self.connection_menu.port = port - fan = QtWidgets.QWidgetAction(self.menu) + self.exit_button = QtWidgets.QPushButton() + self.exit_button.setText("Exit GUI") + self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit) + + exit_action = QtWidgets.QWidgetAction(self.exit_button) + exit_action.setDefaultWidget(self.exit_button) + self.connection_menu.addAction(exit_action) + self.connection_menu.exit_action = exit_action + + self.connect_btn.setMenu(self.connection_menu) + + def _set_up_thermostat_menu(self): + self.thermostat_menu = QtWidgets.QMenu() + self.thermostat_menu.setTitle('Thermostat settings') + + self.fan_group = QtWidgets.QWidget() + self.fan_group.setEnabled(False) + self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group) + self.fan_layout.setSpacing(9) + self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) + self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215)) + self.fan_lbl.setBaseSize(QtCore.QSize(40, 0)) + self.fan_layout.addWidget(self.fan_lbl) + self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group) + self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0)) + self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215)) + self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0)) + self.fan_power_slider.setRange(1, 100) + self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.fan_layout.addWidget(self.fan_power_slider) + self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group) + self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0)) + self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215)) + self.fan_layout.addWidget(self.fan_auto_box) + self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group) + self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0)) + self.fan_layout.addWidget(self.fan_pwm_warning) + + self.fan_power_slider.valueChanged.connect(self.fan_set) + self.fan_auto_box.stateChanged.connect(self.fan_auto_set) + + self.fan_lbl.setToolTip("Adjust the fan") + self.fan_lbl.setText("Fan:") + self.fan_auto_box.setText("Auto") + + fan = QtWidgets.QWidgetAction(self.thermostat_menu) fan.setDefaultWidget(self.fan_group) - self.menu.addAction(fan) - self.menu.fan = fan + self.thermostat_menu.addAction(fan) + self.thermostat_menu.fan = fan - self.thermostat_settings.setMenu(self.menu) + @asyncSlot(bool) + async def reset_thermostat(_): + await self._on_connection_changed(False) + await self.client.reset() + await asyncio.sleep(0.1) # Wait for the reset to start + + self.connect_btn.click() # Reconnect + + self.actionReset.triggered.connect(reset_thermostat) + self.thermostat_menu.addAction(self.actionReset) + + @asyncSlot(bool) + async def dfu_mode(_): + await self._on_connection_changed(False) + await self.client.dfu() + + # TODO: add a firmware flashing GUI? + + self.actionEnter_DFU_Mode.triggered.connect(dfu_mode) + self.thermostat_menu.addAction(self.actionEnter_DFU_Mode) + + @asyncSlot(bool) + async def network_settings(_): + ask_network = QtWidgets.QInputDialog(self) + ask_network.setWindowTitle("Network Settings") + ask_network.setLabelText("Set the Thermostat's IPv4 address, netmask and gateway (optional)") + ask_network.setTextValue((await self.client.ipv4())['addr']) + + @pyqtSlot(str) + def set_ipv4(ipv4_settings): + sure = QtWidgets.QMessageBox(self) + sure.setWindowTitle("Set network?") + sure.setText(f"Setting this as network and disconnecting:
{ipv4_settings}") + + @asyncSlot(object) + async def really_set(button): + await self.client.set_param("ipv4", ipv4_settings) + await self.client.disconnect() + + await self._on_connection_changed(False) + + sure.buttonClicked.connect(really_set) + sure.show() + ask_network.textValueSelected.connect(set_ipv4) + ask_network.show() + + self.actionNetwork_Settings.triggered.connect(network_settings) + self.thermostat_menu.addAction(self.actionNetwork_Settings) + + @asyncSlot(bool) + async def load(_): + await self.client.load_config() + loaded = QtWidgets.QMessageBox(self) + loaded.setWindowTitle("Config loaded") + loaded.setText(f"All channel configs have been loaded from flash.") + loaded.setIcon(QtWidgets.QMessageBox.Icon.Information) + loaded.show() + + self.actionLoad_all_configs.triggered.connect(load) + self.thermostat_menu.addAction(self.actionLoad_all_configs) + + @asyncSlot(bool) + async def save(_): + await self.client.save_config() + saved = QtWidgets.QMessageBox(self) + saved.setWindowTitle("Config saved") + saved.setText(f"All channel configs have been saved to flash.") + saved.setIcon(QtWidgets.QMessageBox.Icon.Information) + saved.show() + + self.actionSave_all_configs.triggered.connect(save) + self.thermostat_menu.addAction(self.actionSave_all_configs) + + def about_thermostat(): + QtWidgets.QMessageBox.about( + self, + "About Thermostat", + f""" +

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() async def _on_connection_changed(self, result): self.graph_group.setEnabled(result) - self.fan_group.setEnabled(result) self.report_group.setEnabled(result) + self.thermostat_settings.setEnabled(result) - self.ip_set_line.setEnabled(not result) + self.host_set_line.setEnabled(not result) self.port_set_spin.setEnabled(not result) self.connect_btn.setText("Disconnect" if result else "Connect") if result: + self.hw_rev_data = await self.client.hw_rev() + self._status(self.hw_rev_data) self.client_watcher.start_watching() - self._status(await self.tec_client.hw_rev()) - self.fan_update(await self.tec_client.fan()) + # await self.client.set_param("fan", 1) else: self.status_lbl.setText("Disconnected") self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.fan_pwm_warning.setToolTip("") + self.clear_graphs() + self.report_box.setChecked(False) + await self.client_watcher.set_report_mode(False) self.client_watcher.stop_watching() + self.status_lbl.setText("Disconnected") def _set_fan_pwm_warning(self): if self.fan_power_slider.value() != 100: @@ -193,7 +630,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.fan_pwm_recommended = hw_rev_d["settings"]["fan_pwm_recommended"] @pyqtSlot(dict) def fan_update(self, fan_settings: dict): @@ -204,90 +640,243 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.fan_power_slider.setValue(fan_settings["fan_pwm"] or 100) # 0 = PWM off = full strength with QSignalBlocker(self.fan_auto_box): self.fan_auto_box.setChecked(fan_settings["auto_mode"]) - if not self.fan_pwm_recommended: + if not self.hw_rev_data["settings"]["fan_pwm_recommended"]: self._set_fan_pwm_warning() @asyncSlot(int) async def fan_set(self, value): - if not self.tec_client.is_connected(): + 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.tec_client.set_param("fan", value) - if not self.fan_pwm_recommended: + 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.tec_client.is_connected(): + if not self.client.connected(): return if enabled: - await self.tec_client.set_param("fan", "auto") - self.fan_update(await self.tec_client.fan()) + await self.client.set_fan("auto") + self.fan_update(await self.client.get_fan()) else: - await self.tec_client.set_param("fan", self.fan_power_slider.value()) + await self.client.set_fan(self.fan_power_slider.value()) + + @asyncSlot(int) + async def on_report_box_stateChanged(self, enabled): + await self.client_watcher.set_report_mode(enabled) @asyncClose async def closeEvent(self, event): - self.client_watcher.stop_watching() - await self.tec_client.disconnect() + await self.bail() @asyncSlot() async def on_connect_btn_clicked(self): - ip, port = self.ip_set_line.text(), self.port_set_spin.value() + host, port = self.host_set_line.text(), self.port_set_spin.value() try: - if not (self.tec_client.is_connecting() or self.tec_client.is_connected()): + if not (self.client.connecting() or self.client.connected()): self.status_lbl.setText("Connecting...") self.connect_btn.setText("Stop") - self.ip_set_line.setEnabled(False) + self.host_set_line.setEnabled(False) self.port_set_spin.setEnabled(False) - connected = await self.tec_client.connect(host=ip, port=port, timeout=30) - if not connected: + try: + await self.client.start_session(host=host, port=port, timeout=30) + except StoppedConnecting: return await self._on_connection_changed(True) else: - await self._on_connection_changed(False) - await self.tec_client.disconnect() + await self.bail() - except (OSError, TimeoutError) as e: - logging.error(f"Failed communicating to {ip}:{port}: {e}") - await self._on_connection_changed(False) - await self.tec_client.disconnect() + except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11 + logging.error(f"Failed communicating to {host}:{port}: {e}") + await self.bail() + + @asyncSlot() + async def bail(self): + await self._on_connection_changed(False) + await self.client.end_session() @asyncSlot(object, object) async def send_command(self, param, changes): - for param, change, data in changes: - if param.name() == 'Temperature PID' and not data: - ch = param.opts["payload"] - await self.tec_client.set_param('pwm', ch, 'i_set', params[ch].child('Constant Current').value()) - elif param.opts.get("commands", None) is not None: - await asyncio.gather(*[self.tec_client._command(x.format(value=data)) for x in param.opts["commands"]]) + """Translates parameter tree changes into thermostat set_param calls""" + + for inner_param, change, data in changes: + if change == 'value': + if inner_param.opts.get("param", None) is not None: + if 'Current' in inner_param.name(): + data /= 1000 # Given in mA + + thermostat_param = inner_param.opts["param"] + if inner_param.name() == 'Postfilter Rate' and data == None: + set_param_args = (*thermostat_param[:2], 'off') + else: + set_param_args = (*thermostat_param, data) + 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)] + if activater is not None: + await self.client.set_param(*activater) + def _set_param_tree(self): - self.ch0_tree.setParameters(params[0], showTop=False) - self.ch1_tree.setParameters(params[1], showTop=False) - params[0].sigTreeStateChanged.connect(self.send_command) - params[1].sigTreeStateChanged.connect(self.send_command) + for i, tree in enumerate((self.ch0_tree, self.ch1_tree)): + tree.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) + + @asyncSlot(list) + async def autotune_tick(self, report): + for channel_report in report: + channel = channel_report['channel'] + match self.autotuners[channel].state(): + case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN: + self.autotuners[channel].run(channel_report['temperature'], channel_report['time']) + await self.client.set_param('pwm', channel, 'i_set', self.autotuners[channel].output()) + case PIDAutotuneState.STATE_SUCCEEDED: + kp, ki, kd = self.autotuners[channel].get_tec_pid() + self.autotuners[channel].setOff() + self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run") + 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)) + 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)) @pyqtSlot(list) def update_pid(self, pid_settings): for settings in pid_settings: channel = settings["channel"] - with QSignalBlocker(params[channel]): - params[channel].child("PID Config", "kP").setValue(settings["parameters"]["kp"]) - params[channel].child("PID Config", "kI").setValue(settings["parameters"]["ki"]) - params[channel].child("PID Config", "kD").setValue(settings["parameters"]["kd"]) - if params[channel].child("Temperature PID").value(): - params[channel].child("Temperature PID", "Set Temperature").setValue(settings["target"]) + with QSignalBlocker(self.params[channel]): + self.params[channel].child("PID Config", "Kp").setValue(settings["parameters"]["kp"]) + self.params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"]) + self.params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"]) + 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)) @pyqtSlot(list) def update_report(self, report_data): for settings in report_data: channel = settings["channel"] - with QSignalBlocker(params[channel]): - params[channel].child("Temperature PID").setValue(settings["pid_engaged"]) + 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) + + @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"]) + + @pyqtSlot(list) + def update_pwm(self, pwm_data): + channels_zeroed_limits = [set() for i in range(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_limits_warning(channels_zeroed_limits) + + @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"]) async def coro_main(): diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui index 4916d43..bcfde55 100644 --- a/pytec/tec_qt.ui +++ b/pytec/tec_qt.ui @@ -25,6 +25,10 @@ Thermostat Control Panel + + + thermostat-icon-640x640.pngthermostat-icon-640x640.png + @@ -96,32 +100,16 @@ - - - Channel 1 Temperature - - + - - - Channel 0 Temperature - - + - - - Channel 0 Current - - + - - - Channel 1 Current - - + @@ -171,69 +159,7 @@ - - - - 0 - 0 - - - - - 160 - 0 - - - - - 160 - 16777215 - - - - 192.168.1.26 - - - 15 - - - IP:port for the Thermostat - - - true - - - - - - - - 0 - 0 - - - - - 70 - 0 - - - - - 70 - 16777215 - - - - 65535 - - - 23 - - - - - + 0 @@ -261,6 +187,12 @@ Connect + + QToolButton::MenuButtonPopup + + + Qt::ToolButtonFollowStyle + @@ -296,6 +228,9 @@ + + false + @@ -305,176 +240,47 @@ - - - - 0 - 0 - + + + Plot Settings - - Qt::Vertical + + 📉 + + + QToolButton::InstantPopup - - - false + + + 1000000000 - + + + + + + Ready. + + + + + + + + + + Qt::Horizontal + + 40 - 0 + 20 - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 9 - - - - - - 16 - 0 - - - - - - - - - - - - 0 - 0 - - - - - 40 - 0 - - - - - 40 - 16777215 - - - - - 40 - 0 - - - - Adjust the fan - - - Fan: - - - - - - - - 0 - 0 - - - - - 200 - 0 - - - - - 200 - 16777215 - - - - - 200 - 0 - - - - 1 - - - 100 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 70 - 0 - - - - - 70 - 16777215 - - - - Auto - - - - - - - - - - - - - 0 - 0 - - - - Qt::Vertical - - + @@ -646,20 +452,92 @@ + + + Reset + + + Reset the Thermostat + + + QAction::NoRole + + + + + Enter DFU Mode + + + Reset thermostat and enter USB device firmware update (DFU) mode + + + QAction::NoRole + + + + + Network Settings + + + Configure IPv4 address, netmask length, and optional default gateway + + + QAction::NoRole + + + + + About Thermostat + + + Show Thermostat hardware revision, and settings related to i + + + QAction::NoRole + + + + + Load all channel configs from flash + + + Restore configuration for all channels from flash + + + QAction::NoRole + + + + + Save all channel configs to flash + + + Save configuration for all channels to flash + + + QAction::NoRole + + - - PlotWidget - QWidget -
pyqtgraph
- 1 -
ParameterTree QWidget
pyqtgraph.parametertree
1
+ + LivePlotWidget + QWidget +
pglive.sources.live_plot_widget
+ 1 +
+ + QtWaitingSpinner + QWidget +
waitingspinnerwidget
+ 1 +
diff --git a/pytec/thermostat-icon-640x640.png b/pytec/thermostat-icon-640x640.png new file mode 100644 index 0000000..12037a6 Binary files /dev/null and b/pytec/thermostat-icon-640x640.png differ diff --git a/pytec/ui_tec_qt.py b/pytec/ui_tec_qt.py index 138cbfe..c7c2a5a 100644 --- a/pytec/ui_tec_qt.py +++ b/pytec/ui_tec_qt.py @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'tec_qt.ui' # -# Created by: PyQt6 UI code generator 6.4.2 +# Created by: PyQt6 UI code generator 6.5.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. @@ -15,6 +15,9 @@ class Ui_MainWindow(object): MainWindow.resize(1280, 720) MainWindow.setMinimumSize(QtCore.QSize(1280, 720)) MainWindow.setMaximumSize(QtCore.QSize(3840, 2160)) + icon = QtGui.QIcon() + icon.addPixmap(QtGui.QPixmap("thermostat-icon-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) @@ -50,16 +53,16 @@ class Ui_MainWindow(object): self.ch0_tree = ParameterTree(parent=self.graph_group) self.ch0_tree.setObjectName("ch0_tree") self.graphs_layout.addWidget(self.ch0_tree, 0, 0, 1, 1) - self.ch1_t_graph = PlotWidget(parent=self.graph_group) + self.ch1_t_graph = LivePlotWidget(parent=self.graph_group) self.ch1_t_graph.setObjectName("ch1_t_graph") self.graphs_layout.addWidget(self.ch1_t_graph, 1, 1, 1, 1) - self.ch0_t_graph = PlotWidget(parent=self.graph_group) + self.ch0_t_graph = LivePlotWidget(parent=self.graph_group) self.ch0_t_graph.setObjectName("ch0_t_graph") self.graphs_layout.addWidget(self.ch0_t_graph, 0, 1, 1, 1) - self.ch0_i_graph = PlotWidget(parent=self.graph_group) + self.ch0_i_graph = LivePlotWidget(parent=self.graph_group) self.ch0_i_graph.setObjectName("ch0_i_graph") self.graphs_layout.addWidget(self.ch0_i_graph, 0, 2, 1, 1) - self.ch1_i_graph = PlotWidget(parent=self.graph_group) + self.ch1_i_graph = LivePlotWidget(parent=self.graph_group) self.ch1_i_graph.setObjectName("ch1_i_graph") self.graphs_layout.addWidget(self.ch1_i_graph, 1, 2, 1, 1) self.graphs_layout.setColumnMinimumWidth(0, 100) @@ -90,31 +93,7 @@ class Ui_MainWindow(object): self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.settings_layout = QtWidgets.QHBoxLayout() self.settings_layout.setObjectName("settings_layout") - self.ip_set_line = QtWidgets.QLineEdit(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.ip_set_line.sizePolicy().hasHeightForWidth()) - self.ip_set_line.setSizePolicy(sizePolicy) - self.ip_set_line.setMinimumSize(QtCore.QSize(160, 0)) - self.ip_set_line.setMaximumSize(QtCore.QSize(160, 16777215)) - self.ip_set_line.setMaxLength(15) - self.ip_set_line.setClearButtonEnabled(True) - self.ip_set_line.setObjectName("ip_set_line") - self.settings_layout.addWidget(self.ip_set_line) - self.port_set_spin = QtWidgets.QSpinBox(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.port_set_spin.sizePolicy().hasHeightForWidth()) - self.port_set_spin.setSizePolicy(sizePolicy) - self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0)) - self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215)) - self.port_set_spin.setMaximum(65535) - self.port_set_spin.setProperty("value", 23) - self.port_set_spin.setObjectName("port_set_spin") - self.settings_layout.addWidget(self.port_set_spin) - self.connect_btn = QtWidgets.QPushButton(parent=self.bottom_settings_group) + 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) @@ -123,6 +102,8 @@ class Ui_MainWindow(object): self.connect_btn.setMinimumSize(QtCore.QSize(100, 0)) self.connect_btn.setMaximumSize(QtCore.QSize(100, 16777215)) self.connect_btn.setBaseSize(QtCore.QSize(100, 0)) + self.connect_btn.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup) + self.connect_btn.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonFollowStyle) self.connect_btn.setObjectName("connect_btn") self.settings_layout.addWidget(self.connect_btn) self.status_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group) @@ -137,83 +118,27 @@ class Ui_MainWindow(object): self.status_lbl.setObjectName("status_lbl") self.settings_layout.addWidget(self.status_lbl) self.thermostat_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group) + self.thermostat_settings.setEnabled(False) self.thermostat_settings.setText("⚙") self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.thermostat_settings.setObjectName("thermostat_settings") self.settings_layout.addWidget(self.thermostat_settings) - self.line_0 = QtWidgets.QFrame(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.line_0.sizePolicy().hasHeightForWidth()) - self.line_0.setSizePolicy(sizePolicy) - self.line_0.setFrameShape(QtWidgets.QFrame.Shape.VLine) - self.line_0.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.line_0.setObjectName("line_0") - self.settings_layout.addWidget(self.line_0) - self.fan_group = QtWidgets.QWidget(parent=self.bottom_settings_group) - self.fan_group.setEnabled(False) - self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) - self.fan_group.setObjectName("fan_group") - self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group) - self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout_6.setSpacing(0) - self.horizontalLayout_6.setObjectName("horizontalLayout_6") - self.gan_layout = QtWidgets.QHBoxLayout() - self.gan_layout.setSpacing(9) - self.gan_layout.setObjectName("gan_layout") - self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group) - self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0)) - self.fan_pwm_warning.setText("") - self.fan_pwm_warning.setObjectName("fan_pwm_warning") - self.gan_layout.addWidget(self.fan_pwm_warning) - self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.fan_lbl.sizePolicy().hasHeightForWidth()) - self.fan_lbl.setSizePolicy(sizePolicy) - self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0)) - self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215)) - self.fan_lbl.setBaseSize(QtCore.QSize(40, 0)) - self.fan_lbl.setObjectName("fan_lbl") - self.gan_layout.addWidget(self.fan_lbl) - self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.fan_power_slider.sizePolicy().hasHeightForWidth()) - self.fan_power_slider.setSizePolicy(sizePolicy) - self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0)) - self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215)) - self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0)) - self.fan_power_slider.setMinimum(1) - self.fan_power_slider.setMaximum(100) - self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal) - self.fan_power_slider.setObjectName("fan_power_slider") - self.gan_layout.addWidget(self.fan_power_slider) - self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.fan_auto_box.sizePolicy().hasHeightForWidth()) - self.fan_auto_box.setSizePolicy(sizePolicy) - self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0)) - self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215)) - self.fan_auto_box.setObjectName("fan_auto_box") - self.gan_layout.addWidget(self.fan_auto_box) - self.horizontalLayout_6.addLayout(self.gan_layout) - self.settings_layout.addWidget(self.fan_group) - self.line_1 = QtWidgets.QFrame(parent=self.bottom_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.line_1.sizePolicy().hasHeightForWidth()) - self.line_1.setSizePolicy(sizePolicy) - self.line_1.setFrameShape(QtWidgets.QFrame.Shape.VLine) - self.line_1.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.line_1.setObjectName("line_1") - self.settings_layout.addWidget(self.line_1) + self.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) @@ -282,6 +207,24 @@ class Ui_MainWindow(object): self.main_layout.addWidget(self.bottom_settings_group) self.gridLayout_2.addLayout(self.main_layout, 0, 1, 1, 1) MainWindow.setCentralWidget(self.main_widget) + self.actionReset = QtGui.QAction(parent=MainWindow) + self.actionReset.setMenuRole(QtGui.QAction.MenuRole.NoRole) + self.actionReset.setObjectName("actionReset") + self.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) @@ -289,23 +232,30 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel")) - self.ch1_t_graph.setTitle(_translate("MainWindow", "Channel 1 Temperature")) - self.ch0_t_graph.setTitle(_translate("MainWindow", "Channel 0 Temperature")) - self.ch0_i_graph.setTitle(_translate("MainWindow", "Channel 0 Current")) - self.ch1_i_graph.setTitle(_translate("MainWindow", "Channel 1 Current")) - self.ip_set_line.setText(_translate("MainWindow", "192.168.1.26")) - self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat")) self.connect_btn.setText(_translate("MainWindow", "Connect")) self.status_lbl.setText(_translate("MainWindow", "Disconnected")) - self.fan_lbl.setToolTip(_translate("MainWindow", "Adjust the fan")) - self.fan_lbl.setText(_translate("MainWindow", "Fan:")) - self.fan_auto_box.setText(_translate("MainWindow", "Auto")) + 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")) -from pyqtgraph import PlotWidget + 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__": diff --git a/pytec/waitingspinnerwidget.py b/pytec/waitingspinnerwidget.py new file mode 100644 index 0000000..2c6e647 --- /dev/null +++ b/pytec/waitingspinnerwidget.py @@ -0,0 +1,194 @@ +""" +The MIT License (MIT) + +Copyright (c) 2012-2014 Alexander Turkin +Copyright (c) 2014 William Hallatt +Copyright (c) 2015 Jacob Dawid +Copyright (c) 2016 Luca Weiss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import math + +from PyQt6.QtCore import * +from PyQt6.QtGui import * +from PyQt6.QtWidgets import * + + +class QtWaitingSpinner(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # WAS IN initialize() + self._color = QColor(Qt.GlobalColor.black) + self._roundness = 100.0 + self._minimumTrailOpacity = 3.14159265358979323846 + self._trailFadePercentage = 80.0 + self._revolutionsPerSecond = 1.57079632679489661923 + self._numberOfLines = 20 + self._lineLength = 5 + self._lineWidth = 2 + self._innerRadius = 5 + self._currentCounter = 0 + + self._timer = QTimer(self) + self._timer.timeout.connect(self.rotate) + self.updateSize() + self.updateTimer() + # END initialize() + + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + painter = QPainter(self) + painter.fillRect(self.rect(), Qt.GlobalColor.transparent) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + + painter.setPen(Qt.PenStyle.NoPen) + for i in range(0, self._numberOfLines): + painter.save() + painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength) + rotateAngle = float(360 * i) / float(self._numberOfLines) + painter.rotate(rotateAngle) + painter.translate(self._innerRadius, 0) + distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines) + color = self.currentLineColor(distance, self._numberOfLines, self._trailFadePercentage, + self._minimumTrailOpacity, self._color) + painter.setBrush(color) + painter.drawRoundedRect(QRect(0, int(-self._lineWidth / 2), self._lineLength, self._lineWidth), self._roundness, + self._roundness, Qt.SizeMode.RelativeSize) + painter.restore() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + self._currentCounter = 0 + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + self._currentCounter = 0 + + def setNumberOfLines(self, lines): + self._numberOfLines = lines + self._currentCounter = 0 + self.updateTimer() + + def setLineLength(self, length): + self._lineLength = length + self.updateSize() + + def setLineWidth(self, width): + self._lineWidth = width + self.updateSize() + + def setInnerRadius(self, radius): + self._innerRadius = radius + self.updateSize() + + def color(self): + return self._color + + def roundness(self): + return self._roundness + + def minimumTrailOpacity(self): + return self._minimumTrailOpacity + + def trailFadePercentage(self): + return self._trailFadePercentage + + def revolutionsPersSecond(self): + return self._revolutionsPerSecond + + def numberOfLines(self): + return self._numberOfLines + + def lineLength(self): + return self._lineLength + + def lineWidth(self): + return self._lineWidth + + def innerRadius(self): + return self._innerRadius + + def setRoundness(self, roundness): + self._roundness = max(0.0, min(100.0, roundness)) + + def setColor(self, color=Qt.GlobalColor.black): + self._color = QColor(color) + + def setRevolutionsPerSecond(self, revolutionsPerSecond): + self._revolutionsPerSecond = revolutionsPerSecond + self.updateTimer() + + def setTrailFadePercentage(self, trail): + self._trailFadePercentage = trail + + def setMinimumTrailOpacity(self, minimumTrailOpacity): + self._minimumTrailOpacity = minimumTrailOpacity + + def rotate(self): + self._currentCounter += 1 + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + self.update() + + def updateSize(self): + self.size = (self._innerRadius + self._lineLength) * 2 + self.setFixedSize(self.size, self.size) + + def updateTimer(self): + self._timer.setInterval(int(1000 / (self._numberOfLines * self._revolutionsPerSecond))) + + def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines): + distance = primary - current + if distance < 0: + distance += totalNrOfLines + return distance + + def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput): + color = QColor(colorinput) + if countDistance == 0: + return color + minAlphaF = minOpacity / 100.0 + distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)) + if countDistance > distanceThreshold: + color.setAlphaF(minAlphaF) + else: + alphaDiff = color.alphaF() - minAlphaF + gradient = alphaDiff / float(distanceThreshold + 1) + resultAlpha = color.alphaF() - gradient * countDistance + # If alpha is out of bounds, clip it. + resultAlpha = min(1.0, max(0.0, resultAlpha)) + color.setAlphaF(resultAlpha) + return color + + +if __name__ == '__main__': + app = QApplication([]) + waiting_spinner = QtWaitingSpinner() + waiting_spinner.show() + waiting_spinner.start() + app.exec()