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"""
+