diff --git a/README.md b/README.md index 55d756e..1abb985 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,19 @@ On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit" ``` -## Network +## GUI Usage + +A GUI has been developed for easy configuration and plotting of key parameters. + +The Python GUI program is located at pytec/tec_qt.py. + +The GUI is developed based on the Python library pyqtgraph. The GUI can be configured and launched automatically by running: + +``` +nix run .#thermostat_gui +``` + +## Command Line Usage ### Connecting diff --git a/flake.lock b/flake.lock index 79fe89d..21877f4 100644 --- a/flake.lock +++ b/flake.lock @@ -18,16 +18,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1691421349, - "narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=", + "lastModified": 1701156937, + "narHash": "sha256-jpMJOFvOTejx211D8z/gz0ErRtQPy6RXxgD2ZB86mso=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "011567f35433879aae5024fc6ec53f2a0568a6c4", + "rev": "7c4c20509c4363195841faa6c911777a134acdf3", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-23.05", + "ref": "nixos-23.11", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 803e774..d451e84 100644 --- a/flake.nix +++ b/flake.nix @@ -1,7 +1,7 @@ { description = "Firmware for the Sinara 8451 Thermostat"; - inputs.nixpkgs.url = github:NixOS/nixpkgs/nixos-23.05; + inputs.nixpkgs.url = github:NixOS/nixpkgs/nixos-23.11; inputs.mozilla-overlay = { url = github:mozilla/nixpkgs-mozilla; flake = false; }; outputs = { self, nixpkgs, mozilla-overlay }: @@ -55,9 +55,41 @@ dontFixup = true; }; + + pglive = pkgs.python3Packages.buildPythonPackage rec { + pname = "pglive"; + version = "0.7.2"; + format = "pyproject"; + src = pkgs.fetchPypi { + inherit pname version; + hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; + }; + buildInputs = [ pkgs.python3Packages.poetry-core ]; + propagatedBuildInputs = with pkgs.python3Packages; [ pyqtgraph 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 pglive ]); + + dontWrapQtApps = true; + postFixup = '' + wrapQtApp "$out/bin/tec_qt" + ''; + }; in { packages.x86_64-linux = { - inherit thermostat; + inherit thermostat thermostat_gui; + }; + + apps.x86_64-linux.thermostat_gui = { + type = "app"; + program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt"; }; hydraJobs = { @@ -69,7 +101,7 @@ buildInputs = with pkgs; [ rust openocd dfu-util ] ++ (with python3Packages; [ - numpy matplotlib + numpy matplotlib pyqtgraph setuptools pyqt6 qasync pglive ]); }; defaultPackage.x86_64-linux = thermostat; diff --git a/pytec/aioexample.py b/pytec/aioexample.py new file mode 100644 index 0000000..42c02b4 --- /dev/null +++ b/pytec/aioexample.py @@ -0,0 +1,16 @@ +import asyncio +from pytec.aioclient import Client + +async def main(): + tec = Client() + 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()) + print(await tec.get_pwm()) + print(await tec.get_postfilter()) + print(await tec.get_steinhart_hart()) + async for data in tec.report_mode(): + print(data) + +asyncio.run(main()) diff --git a/pytec/autotune.py b/pytec/autotune.py index c1f593e..c7d4dda 100644 --- a/pytec/autotune.py +++ b/pytec/autotune.py @@ -17,6 +17,7 @@ class PIDAutotuneState(Enum): STATE_RELAY_STEP_DOWN = 'relay step down' STATE_SUCCEEDED = 'succeeded' STATE_FAILED = 'failed' + STATE_READY = 'ready' class PIDAutotune: @@ -56,6 +57,20 @@ class PIDAutotune: self._Ku = 0 self._Pu = 0 + def setParam(self, target, step, noiseband, sampletime, lookback): + self._setpoint = target + self._outputstep = step + self._out_max = step + self._out_min = -step + self._noiseband = noiseband + self._inputs = deque(maxlen=round(lookback / sampletime)) + + def setReady(self): + self._state = PIDAutotuneState.STATE_READY + + def setOff(self): + self._state = PIDAutotuneState.STATE_OFF + def state(self): """Get the current state.""" return self._state @@ -81,6 +96,13 @@ class PIDAutotune: kd = divisors[2] * self._Ku * self._Pu return PIDAutotune.PIDParams(kp, ki, kd) + def get_tec_pid (self): + divisors = self._tuning_rules["tyreus-luyben"] + kp = self._Ku * divisors[0] + ki = divisors[1] * self._Ku / self._Pu + kd = divisors[2] * self._Ku * self._Pu + return kp, ki, kd + def run(self, input_val, time_input): """To autotune a system, this method must be called periodically. @@ -95,7 +117,8 @@ class PIDAutotune: if (self._state == PIDAutotuneState.STATE_OFF or self._state == PIDAutotuneState.STATE_SUCCEEDED - or self._state == PIDAutotuneState.STATE_FAILED): + or self._state == PIDAutotuneState.STATE_FAILED + or self._state == PIDAutotuneState.STATE_READY): self._state = PIDAutotuneState.STATE_RELAY_STEP_UP self._last_run_timestamp = now @@ -199,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/plot.py b/pytec/plot.py index 4a1e6da..bcb0c3b 100644 --- a/pytec/plot.py +++ b/pytec/plot.py @@ -30,15 +30,15 @@ class Series: series = { # 'adc': Series(), # 'sens': Series(lambda x: x * 0.0001), - 'temperature': Series(), - # 'i_set': Series(), - 'pid_output': Series(), + # 'temperature': Series(), + 'i_set': Series(), + # 'pid_output': Series(), # 'vref': Series(), # 'dac_value': Series(), # 'dac_feedback': Series(), - # 'i_tec': Series(), + 'i_tec': Series(), 'tec_i': Series(), - 'tec_u_meas': Series(), + # 'tec_u_meas': Series(), # 'interval': Series(), } series_lock = Lock() diff --git a/pytec/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 new file mode 100644 index 0000000..1054afa --- /dev/null +++ b/pytec/pytec/aioclient.py @@ -0,0 +1,279 @@ +import asyncio +import json +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 start_session(self, host='192.168.1.26', port=23, timeout=None): + """Start session to Thermostat at specified host and port. + Throws StoppedConnecting if disconnect was called while connecting. + Throws asyncio.TimeoutError if timeout was exceeded. + + Example:: + client = Client() + try: + await client.start_session() + except StoppedConnecting: + print("Stopped connecting") + """ + 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: + raise StoppedConnecting + finally: + self._connecting_task = None + + await self._check_zero_limits() + + def connecting(self): + """Returns True if client is connecting""" + return self._connecting_task is not None + + def connected(self): + """Returns True if client is connected""" + return self._writer is not None + + 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() + + if self._writer is None: + return + + # Reader needn't be closed + self._writer.close() + await self._writer.wait_closed() + self._reader = None + self._writer = None + + async def _check_zero_limits(self): + pwm_report = await self.get_pwm() + for pwm_channel in pwm_report: + for limit in ["max_i_neg", "max_i_pos", "max_v"]: + if pwm_channel[limit]["value"] == 0.0: + logging.warning("`{}` limit is set to zero on channel {}".format(limit, pwm_channel["channel"])) + + async def _read_line(self): + # read 1 line + chunk = await 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: + # 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}") + if "error" in response: + raise CommandError(response["error"]) + return response + + async def _get_conf(self, topic): + result = [None, None] + for item in await self._command(topic): + result[int(item["channel"])] = item + return result + + async def get_pwm(self): + """Retrieve PWM limits for the TEC + + Example:: + [{'channel': 0, + 'center': 'vref', + 'i_set': {'max': 2.9802790335151985, 'value': -0.02002179650216762}, + 'max_i_neg': {'max': 3.0, 'value': 3.0}, + 'max_v': {'max': 5.988, 'value': 5.988}, + 'max_i_pos': {'max': 3.0, 'value': 3.0}}, + {'channel': 1, + 'center': 'vref', + 'i_set': {'max': 2.9802790335151985, 'value': -0.02002179650216762}, + 'max_i_neg': {'max': 3.0, 'value': 3.0}, + 'max_v': {'max': 5.988, 'value': 5.988}, + 'max_i_pos': {'max': 3.0, 'value': 3.0}} + ] + """ + return await self._get_conf("pwm") + + async def get_pid(self): + """Retrieve PID control state + + Example:: + [{'channel': 0, + 'parameters': { + 'kp': 10.0, + 'ki': 0.02, + 'kd': 0.0, + 'output_min': 0.0, + 'output_max': 3.0}, + 'target': 37.0}, + {'channel': 1, + 'parameters': { + 'kp': 10.0, + 'ki': 0.02, + 'kd': 0.0, + 'output_min': 0.0, + 'output_max': 3.0}, + 'target': 36.5}] + """ + return await self._get_conf("pid") + + async def get_steinhart_hart(self): + """Retrieve Steinhart-Hart parameters for resistance to temperature conversion + + Example:: + [{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0}, + {'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}] + """ + return await self._get_conf("s-h") + + async def get_postfilter(self): + """Retrieve DAC postfilter configuration + + Example:: + [{'rate': None, 'channel': 0}, + {'rate': 21.25, 'channel': 1}] + """ + return await self._get_conf("postfilter") + + async def 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 + + Example of yielded data:: + {'channel': 0, + 'time': 2302524, + 'adc': 0.6199188965423515, + 'sens': 6138.519310282602, + 'temperature': 36.87032392655527, + 'pid_engaged': True, + 'i_set': 2.0635816680889123, + 'vref': 1.494, + 'dac_value': 2.527790834044456, + 'dac_feedback': 2.523, + 'i_tec': 2.331, + 'tec_i': 2.0925, + 'tec_u_meas': 2.5340000000000003, + 'pid_output': 2.067581958092247} + """ + await self._command("report mode", "on") + self._report_mode_on = True + + while self._report_mode_on: + async with self._command_lock: + line = await self._read_line() + if not line: + break + try: + yield json.loads(line) + 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 + + Examples:: + await tec.set_param("pwm", 0, "max_v", 2.0) + await tec.set_param("pid", 1, "output_max", 2.5) + await tec.set_param("s-h", 0, "t0", 20.0) + await tec.set_param("center", 0, "vref") + await tec.set_param("postfilter", 1, 21) + + See the firmware's README.md for a full list. + """ + if type(value) is float: + value = "{:f}".format(value) + if type(value) is not str: + value = str(value) + await self._command(topic, str(channel), field, value) + + async def 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, channel=""): + """Save current configuration to EEPROM""" + await self._command("save", str(channel)) + + async def load_config(self, channel=""): + """Load current configuration from EEPROM""" + await self._command("load", str(channel)) + if channel == "": + await self._read_line() # Read the extra {} + + async def hw_rev(self): + """Get Thermostat hardware revision""" + return await self._command("hwrev") + + 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/pytec/client.py b/pytec/pytec/client.py index 062d8dc..c9da63a 100644 --- a/pytec/pytec/client.py +++ b/pytec/pytec/client.py @@ -11,6 +11,10 @@ class Client: self._lines = [""] self._check_zero_limits() + def disconnect(self): + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + def _check_zero_limits(self): pwm_report = self.get_pwm() for pwm_channel in pwm_report: @@ -32,10 +36,11 @@ class Client: return line def _command(self, *command): - self._socket.sendall((" ".join(command) + "\n").encode('utf-8')) + self._socket.sendall(((" ".join(command)).strip() + "\n").encode('utf-8')) line = self._read_line() response = json.loads(line) + logging.debug(f"{command}: {response}") if "error" in response: raise CommandError(response["error"]) return response @@ -167,3 +172,11 @@ class Client: def load_config(self): """Load current configuration from EEPROM""" self._command("load") + + def hw_rev(self): + """Get Thermostat hardware revision""" + return self._command("hwrev") + + def fan(self): + """Get Thermostat current fan settings""" + return self._command("fan") diff --git a/pytec/setup.py b/pytec/setup.py index 3a46a57..4ac8f58 100644 --- a/pytec/setup.py +++ b/pytec/setup.py @@ -9,4 +9,10 @@ setup( license="GPLv3", install_requires=["setuptools"], packages=find_packages(), + entry_points={ + "gui_scripts": [ + "tec_qt = tec_qt:main", + ] + }, + py_modules=['tec_qt', 'ui_tec_qt', 'autotune', 'waitingspinnerwidget'], ) diff --git a/pytec/tec_qt.py b/pytec/tec_qt.py new file mode 100644 index 0000000..db1fbc0 --- /dev/null +++ b/pytec/tec_qt.py @@ -0,0 +1,903 @@ +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 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 + +"""Number of channels provided by the Thermostat""" +NUM_CHANNELS: int = 2 + +def get_argparser(): + parser = argparse.ArgumentParser(description="ARTIQ master") + + parser.add_argument("--connect", default=None, action="store_true", + help="Automatically connect to the specified Thermostat in IP:port format") + parser.add_argument('IP', metavar="ip", default=None, nargs='?') + parser.add_argument('PORT', metavar="port", default=None, nargs='?') + parser.add_argument("-l", "--log", dest="logLevel", choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + help="Set the logging level") + + return parser + + +class 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) as e: + 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)] + + def __init__(self, args): + super().__init__() + + self.setupUi(self) + + self.ch0_t_graph.setTitle("Channel 0 Temperature") + self.ch0_i_graph.setTitle("Channel 0 Current") + self.ch1_t_graph.setTitle("Channel 1 Temperature") + self.ch1_i_graph.setTitle("Channel 1 Current") + + self.max_samples = self.DEFAULT_MAX_SAMPLES + + self._set_up_connection_menu() + self._set_up_thermostat_menu() + self._set_up_plot_menu() + + 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.host_set_line.setText(args.IP) + if args.PORT: + 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() + + 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.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() + # 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: + 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("") + + 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"]) + + @pyqtSlot(dict) + def fan_update(self, fan_settings: dict): + 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"]) + 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()) + + @asyncSlot(int) + async def on_report_box_stateChanged(self, enabled): + await self.client_watcher.set_report_mode(enabled) + + @asyncClose + async def closeEvent(self, event): + await self.bail() + + @asyncSlot() + async def on_connect_btn_clicked(self): + host, port = self.host_set_line.text(), self.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) + + try: + await self.client.start_session(host=host, port=port, timeout=30) + except StoppedConnecting: + return + await self._on_connection_changed(True) + else: + await self.bail() + + except (OSError, TimeoutError) as e: + 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): + """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): + 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(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"] + 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(): + args = get_argparser().parse_args() + if args.logLevel: + logging.basicConfig(level=getattr(logging, args.logLevel)) + + app_quit_event = asyncio.Event() + + app = QtWidgets.QApplication.instance() + app.aboutToQuit.connect(app_quit_event.set) + + main_window = MainWindow(args) + main_window.show() + + await app_quit_event.wait() + + +def main(): + qasync.run(coro_main()) + + +if __name__ == '__main__': + main() diff --git a/pytec/tec_qt.ui b/pytec/tec_qt.ui new file mode 100644 index 0000000..bcfde55 --- /dev/null +++ b/pytec/tec_qt.ui @@ -0,0 +1,544 @@ + + + MainWindow + + + + 0 + 0 + 1280 + 720 + + + + + 1280 + 720 + + + + + 3840 + 2160 + + + + Thermostat Control Panel + + + + thermostat-icon-640x640.pngthermostat-icon-640x640.png + + + + + 1 + 1 + + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 0 + + + + + false + + + + 1 + 1 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + QLayout::SetDefaultConstraint + + + 3 + + + 3 + + + 3 + + + 3 + + + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + + 0 + 40 + + + + + 16777215 + 40 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + + 100 + 0 + + + + Connect + + + QToolButton::MenuButtonPopup + + + Qt::ToolButtonFollowStyle + + + + + + + + 0 + 0 + + + + + 240 + 0 + + + + + 120 + 16777215 + + + + + 120 + 50 + + + + Disconnected + + + + + + + false + + + + + + QToolButton::InstantPopup + + + + + + + Plot Settings + + + 📉 + + + QToolButton::InstantPopup + + + + + + + 1000000000 + + + + + + + Ready. + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + false + + + + 0 + 0 + + + + + 40 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 6 + + + QLayout::SetDefaultConstraint + + + 0 + + + + + Poll every: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + + 70 + 0 + + + + s + + + 1 + + + 0.100000000000000 + + + 0.100000000000000 + + + QAbstractSpinBox::AdaptiveDecimalStepType + + + 1.000000000000000 + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 80 + 0 + + + + Report + + + + + + + + 0 + 0 + + + + + 80 + 0 + + + + + 80 + 16777215 + + + + + 80 + 0 + + + + Apply + + + + + + + + + + + + + + + + + + + + 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 + + + + + + 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 new file mode 100644 index 0000000..c7c2a5a --- /dev/null +++ b/pytec/ui_tec_qt.py @@ -0,0 +1,268 @@ +# 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/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() diff --git a/src/channels.rs b/src/channels.rs index 5bf0ee6..38d0677 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -113,11 +113,19 @@ impl<'a> Channels<'a> { } pub fn get_i(&mut self, channel: usize) -> ElectricCurrent { - let i_set = self.channel_state(channel).i_set; + let center_point = match channel.into() { + 0 => self.channel0.vref_meas, + 1 => self.channel1.vref_meas, + _ => unreachable!(), + }; + // let i_set = self.channel_state(channel).i_set; + let r_sense = ElectricalResistance::new::(R_SENSE); + let voltage = self.get_dac(channel); + let i_tec = (voltage - center_point) / (10.0 * r_sense); if self.channel_state(channel).swap_tec_polarity { - -i_set + -i_tec } else { - i_set + i_tec } } @@ -133,22 +141,24 @@ impl<'a> Channels<'a> { voltage } - pub fn set_i(&mut self, channel: usize, mut i_set: ElectricCurrent) -> ElectricCurrent { + pub fn set_i(&mut self, channel: usize, i_tec: ElectricCurrent) -> ElectricCurrent { let vref_meas = match channel.into() { 0 => self.channel0.vref_meas, 1 => self.channel1.vref_meas, _ => unreachable!(), }; - if self.channel_state(channel).swap_tec_polarity { - i_set = -i_set; - } + let i_set = if self.channel_state(channel).swap_tec_polarity { + -i_tec + } else { + i_tec + }; let center_point = vref_meas; let r_sense = ElectricalResistance::new::(R_SENSE); let voltage = i_set * 10.0 * r_sense + center_point; let voltage = self.set_dac(channel, voltage); - let i_set = (voltage - center_point) / (10.0 * r_sense); + let i_tec = (voltage - center_point) / (10.0 * r_sense); self.channel_state(channel).i_set = i_set; - i_set + i_tec } pub fn read_dac_feedback(&mut self, channel: usize) -> ElectricPotential {