diff --git a/.gitignore b/.gitignore
index e018296..f07a761 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ result
*.bin
__pycache__/
+*.pyc
\ No newline at end of file
diff --git a/flake.nix b/flake.nix
index 64f69e3..2fdd19d 100644
--- a/flake.nix
+++ b/flake.nix
@@ -57,6 +57,29 @@
dontFixup = true;
auditable = false;
};
+
+ pyqtgraph = pkgs.python3Packages.buildPythonPackage rec {
+ pname = "pyqtgraph";
+ version = "0.13.3";
+ format = "pyproject";
+ src = pkgs.fetchPypi {
+ inherit pname version;
+ hash = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4=";
+ };
+ propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ];
+ };
+
+ pglive = pkgs.python3Packages.buildPythonPackage rec {
+ pname = "pglive";
+ version = "0.7.2";
+ format = "pyproject";
+ src = pkgs.fetchPypi {
+ inherit pname version;
+ hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A=";
+ };
+ buildInputs = [ pkgs.python3Packages.poetry-core ];
+ propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ];
+ };
in
{
packages.x86_64-linux = {
@@ -81,7 +104,7 @@
]
++ (with python3Packages; [
numpy
- matplotlib
+ matplotlib pyqtgraph setuptools pyqt6 qasync pglive
]);
shellHook = ''
export PYTHONPATH=`pwd`/pytec:$PYTHONPATH
diff --git a/pytec/autotune.py b/pytec/autotune.py
index 1a868fd..be76e82 100644
--- a/pytec/autotune.py
+++ b/pytec/autotune.py
@@ -68,6 +68,7 @@ class PIDAutotune:
def setReady(self):
self._state = PIDAutotuneState.STATE_READY
+ self._peak_count = 0
def setOff(self):
self._state = PIDAutotuneState.STATE_OFF
diff --git a/pytec/examples/aioexample.py b/pytec/examples/aioexample.py
new file mode 100644
index 0000000..02535d2
--- /dev/null
+++ b/pytec/examples/aioexample.py
@@ -0,0 +1,36 @@
+import asyncio
+from contextlib import suppress
+from pytec.aioclient import AsyncioClient
+
+
+async def poll_for_info(tec):
+ while True:
+ print(tec.get_pwm())
+ print(tec.get_steinhart_hart())
+ print(tec.get_pid())
+ print(tec.get_postfilter())
+ print(tec.get_fan())
+
+ await asyncio.sleep(1)
+
+
+async def main():
+ tec = AsyncioClient()
+ await tec.connect() # (host="192.168.1.26", port=23)
+ await tec.set_param("s-h", 1, "t0", 20)
+ print(await tec.get_output())
+ print(await tec.get_pid())
+ print(await tec.get_output())
+ print(await tec.get_postfilter())
+ print(await tec.get_b_parameter())
+
+ polling_task = asyncio.create_task(poll_for_info(tec))
+
+ async for data in tec.report_mode():
+ print(data)
+
+ polling_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await polling_task
+
+asyncio.run(main())
diff --git a/pytec/example.py b/pytec/examples/example.py
similarity index 100%
rename from pytec/example.py
rename to pytec/examples/example.py
diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py
new file mode 100644
index 0000000..f023a3e
--- /dev/null
+++ b/pytec/pytec/aioclient.py
@@ -0,0 +1,261 @@
+import asyncio
+import json
+import logging
+
+
+class CommandError(Exception):
+ pass
+
+
+class AsyncioClient:
+ def __init__(self):
+ self._reader = None
+ self._writer = None
+ self._command_lock = asyncio.Lock()
+ self._report_mode_on = False
+
+ async def connect(self, host="192.168.1.26", port=23):
+ """Connect to Thermostat at specified host and port.
+
+ Example::
+ client = AsyncioClient()
+ await client.connect()
+ """
+ self._reader, self._writer = await asyncio.open_connection(host, port)
+ await self._check_zero_limits()
+
+ def connected(self):
+ """Returns True if client is connected"""
+ return self._writer is not None
+
+ async def disconnect(self):
+ """Disconnect from the Thermostat"""
+
+ 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):
+ output_report = await self.get_output()
+ for output_channel in output_report:
+ for limit in ["max_i_neg", "max_i_pos", "max_v"]:
+ if output_channel[limit] == 0.0:
+ logging.warning(
+ "`{}` limit is set to zero on channel {}".format(
+ limit, output_channel["channel"]
+ )
+ )
+
+ async def _read_line(self):
+ # read 1 line
+ chunk = await self._reader.readline()
+ 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:
+ line = await self._read_write(command)
+
+ response = json.loads(line)
+ 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_output(self):
+ """Retrieve output limits for the TEC
+
+ Example::
+ [{'channel': 0,
+ 'center': 'vref',
+ 'i_set': -0.02002179650216762,
+ 'max_i_neg': 2.0,
+ 'max_v': : 3.988,
+ 'max_i_pos': 2.0,
+ 'polarity': 'normal'},
+ {'channel': 1,
+ 'center': 'vref',
+ 'i_set': -0.02002179650216762,
+ 'max_i_neg': 2.0,
+ 'max_v': : 3.988,
+ 'max_i_pos': 2.0,
+ 'polarity': 'normal'},
+ ]
+ """
+ return await self._get_conf("output")
+
+ 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_b_parameter(self):
+ """Retrieve B-Parameter equation 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("b-p")
+
+ 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("output", 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("output", channel, "pid")
+
+ async def save_config(self, channel=""):
+ """Save current configuration to EEPROM"""
+ await self._command("save", str(channel))
+ if channel == "":
+ await self._read_line() # Read the extra {}
+
+ 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.disconnect()
+
+ async def dfu(self):
+ """Put the Thermostat in DFU mode
+
+ The client is disconnected as the Thermostat stops responding to
+ TCP commands in DFU mode. To exit it, submit a DFU leave request
+ or power-cycle the Thermostat.
+ """
+ async with self._command_lock:
+ self._writer.write("dfu\n".encode("utf-8"))
+ await self._writer.drain()
+
+ await self.disconnect()
+
+ 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 12eeb0d..985a63d 100644
--- a/pytec/pytec/client.py
+++ b/pytec/pytec/client.py
@@ -1,6 +1,7 @@
import socket
import json
import logging
+
import time
@@ -14,6 +15,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):
output_report = self.get_output()
for output_channel in output_report:
@@ -176,3 +181,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/pytec/gui/model/pid_autotuner.py b/pytec/pytec/gui/model/pid_autotuner.py
new file mode 100644
index 0000000..75bd3e9
--- /dev/null
+++ b/pytec/pytec/gui/model/pid_autotuner.py
@@ -0,0 +1,84 @@
+from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal
+from qasync import asyncSlot
+from autotune import PIDAutotuneState, PIDAutotune
+
+
+class PIDAutoTuner(QObject):
+ autotune_state_changed = pyqtSignal(int, PIDAutotuneState)
+
+ def __init__(self, parent, thermostat, num_of_channel):
+ super().__init__(parent)
+
+ self._thermostat = thermostat
+ self._thermostat.report_update.connect(self.tick)
+
+ self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
+ self.target_temp = [20.0 for _ in range(num_of_channel)]
+ self.test_current = [1.0 for _ in range(num_of_channel)]
+ self.temp_swing = [1.5 for _ in range(num_of_channel)]
+ self.lookback = [3.0 for _ in range(num_of_channel)]
+ self.sampling_interval = [1 / 16.67 for _ in range(num_of_channel)]
+
+ def set_params(self, params_name, ch, val):
+ getattr(self, params_name)[ch] = val
+
+ def get_state(self, ch):
+ return self.autotuners[ch].state()
+
+ def load_params_and_set_ready(self, ch):
+ self.autotuners[ch].setParam(
+ self.target_temp[ch],
+ self.test_current[ch] / 1000,
+ self.temp_swing[ch],
+ 1 / self.sampling_interval[ch],
+ self.lookback[ch],
+ )
+ self.autotuners[ch].setReady()
+ self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
+
+ async def stop_pid_from_running(self, ch):
+ self.autotuners[ch].setOff()
+ self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
+ if self._thermostat.connected():
+ await self._thermostat.set_param("pwm", ch, "i_set", 0)
+
+ @asyncSlot(list)
+ async def tick(self, report):
+ for channel_report in report:
+ ch = channel_report["channel"]
+
+ self.sampling_interval[ch] = channel_report["interval"]
+
+ # TODO: Skip when PID Autotune or emit error message if NTC is not connected
+ if channel_report["temperature"] is None:
+ continue
+
+ match self.autotuners[ch].state():
+ case (
+ PIDAutotuneState.STATE_READY
+ | PIDAutotuneState.STATE_RELAY_STEP_UP
+ | PIDAutotuneState.STATE_RELAY_STEP_DOWN
+ ):
+ self.autotuners[ch].run(
+ channel_report["temperature"], channel_report["time"]
+ )
+ await self._thermostat.set_param(
+ "pwm", ch, "i_set", self.autotuners[ch].output()
+ )
+ case PIDAutotuneState.STATE_SUCCEEDED:
+ kp, ki, kd = self.autotuners[ch].get_tec_pid()
+ self.autotuners[ch].setOff()
+ self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
+
+ await self._thermostat.set_param("pid", ch, "kp", kp)
+ await self._thermostat.set_param("pid", ch, "ki", ki)
+ await self._thermostat.set_param("pid", ch, "kd", kd)
+ await self._thermostat.set_param("pwm", ch, "pid")
+
+ await self._thermostat.set_param(
+ "pid", ch, "target", self.target_temp[ch]
+ )
+ case PIDAutotuneState.STATE_FAILED:
+ self.autotuners[ch].setOff()
+ self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
+ await self._thermostat.set_param("pwm", ch, "i_set", 0)
diff --git a/pytec/pytec/gui/model/property.py b/pytec/pytec/gui/model/property.py
new file mode 100644
index 0000000..badea1c
--- /dev/null
+++ b/pytec/pytec/gui/model/property.py
@@ -0,0 +1,126 @@
+# A Custom Class that allows defining a QObject Property Dynamically
+# Adapted from: https://stackoverflow.com/questions/48425316/how-to-create-pyqt-properties-dynamically
+
+from functools import wraps
+
+from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal
+
+
+class PropertyMeta(type(QObject)):
+ """Lets a class succinctly define Qt properties."""
+
+ def __new__(cls, name, bases, attrs):
+ for key in list(attrs.keys()):
+ attr = attrs[key]
+ if not isinstance(attr, Property):
+ continue
+
+ types = {list: "QVariantList", dict: "QVariantMap"}
+ type_ = types.get(attr.type_, attr.type_)
+
+ notifier = pyqtSignal(type_)
+ attrs[f"{key}_update"] = notifier
+ attrs[key] = PropertyImpl(type_=type_, name=key, notify=notifier)
+
+ return super().__new__(cls, name, bases, attrs)
+
+
+class Property:
+ """Property definition.
+
+ Instances of this class will be replaced with their full
+ implementation by the PropertyMeta metaclass.
+ """
+
+ def __init__(self, type_):
+ self.type_ = type_
+
+
+class PropertyImpl(pyqtProperty):
+ """Property implementation: gets, sets, and notifies of change."""
+
+ def __init__(self, type_, name, notify):
+ super().__init__(type_, self.getter, self.setter, notify=notify)
+ self.name = name
+
+ def getter(self, instance):
+ return getattr(instance, f"_{self.name}")
+
+ def setter(self, instance, value):
+ signal = getattr(instance, f"{self.name}_update")
+
+ if type(value) in {list, dict}:
+ value = make_notified(value, signal)
+
+ setattr(instance, f"_{self.name}", value)
+ signal.emit(value)
+
+
+class MakeNotified:
+ """Adds notifying signals to lists and dictionaries.
+
+ Creates the modified classes just once, on initialization.
+ """
+
+ change_methods = {
+ list: [
+ "__delitem__",
+ "__iadd__",
+ "__imul__",
+ "__setitem__",
+ "append",
+ "extend",
+ "insert",
+ "pop",
+ "remove",
+ "reverse",
+ "sort",
+ ],
+ dict: [
+ "__delitem__",
+ "__ior__",
+ "__setitem__",
+ "clear",
+ "pop",
+ "popitem",
+ "setdefault",
+ "update",
+ ],
+ }
+
+ def __init__(self):
+ if not hasattr(dict, "__ior__"):
+ # Dictionaries don't have | operator in Python < 3.9.
+ self.change_methods[dict].remove("__ior__")
+ self.notified_class = {
+ type_: self.make_notified_class(type_) for type_ in [list, dict]
+ }
+
+ def __call__(self, seq, signal):
+ """Returns a notifying version of the supplied list or dict."""
+ notified_class = self.notified_class[type(seq)]
+ notified_seq = notified_class(seq)
+ notified_seq.signal = signal
+ return notified_seq
+
+ @classmethod
+ def make_notified_class(cls, parent):
+ notified_class = type(f"notified_{parent.__name__}", (parent,), {})
+ for method_name in cls.change_methods[parent]:
+ original = getattr(notified_class, method_name)
+ notified_method = cls.make_notified_method(original, parent)
+ setattr(notified_class, method_name, notified_method)
+ return notified_class
+
+ @staticmethod
+ def make_notified_method(method, parent):
+ @wraps(method)
+ def notified_method(self, *args, **kwargs):
+ result = getattr(parent, method.__name__)(self, *args, **kwargs)
+ self.signal.emit(self)
+ return result
+
+ return notified_method
+
+
+make_notified = MakeNotified()
diff --git a/pytec/pytec/gui/model/thermostat.py b/pytec/pytec/gui/model/thermostat.py
new file mode 100644
index 0000000..a4f1604
--- /dev/null
+++ b/pytec/pytec/gui/model/thermostat.py
@@ -0,0 +1,130 @@
+from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
+from qasync import asyncSlot
+from pytec.gui.model.property import Property, PropertyMeta
+import asyncio
+import logging
+from enum import Enum
+from pytec.aioclient import AsyncioClient
+
+
+class ThermostatConnectionState(Enum):
+ DISCONNECTED = "disconnected"
+ CONNECTING = "connecting"
+ CONNECTED = "connected"
+
+
+class Thermostat(QObject, metaclass=PropertyMeta):
+ connection_state = Property(ThermostatConnectionState)
+ hw_rev = Property(dict)
+ fan = Property(dict)
+ thermistor = Property(list)
+ pid = Property(list)
+ pwm = Property(list)
+ postfilter = Property(list)
+ report = Property(list)
+
+ connection_error = pyqtSignal()
+
+ NUM_CHANNELS = 2
+
+ def __init__(self, parent, update_s, disconnect_cb=None):
+ super().__init__(parent)
+
+ self._update_s = update_s
+ self._client = AsyncioClient()
+ self._watch_task = None
+ self._update_params_task = None
+ self.disconnect_cb = disconnect_cb
+ self.connection_state = ThermostatConnectionState.DISCONNECTED
+
+ async def start_session(self, host, port):
+ await self._client.connect(host, port)
+ self.hw_rev = await self._client.hw_rev()
+
+ @asyncSlot()
+ async def end_session(self):
+ self.stop_watching()
+
+ if self.disconnect_cb is not None:
+ if asyncio.iscoroutinefunction(self.disconnect_cb):
+ await self.disconnect_cb()
+ else:
+ self.disconnect_cb()
+
+ await self._client.disconnect()
+
+ def start_watching(self):
+ self._watch_task = asyncio.create_task(self.run())
+
+ def stop_watching(self):
+ if self._watch_task is not None:
+ self._watch_task.cancel()
+ self._watch_task = None
+ self._update_params_task.cancel()
+ self._update_params_task = None
+
+ async def run(self):
+ self._update_params_task = asyncio.create_task(self.update_params())
+ while True:
+ if self._update_params_task.done():
+ try:
+ self._update_params_task.result()
+ except OSError:
+ logging.error(
+ "Encountered an error while polling for information from Thermostat.",
+ exc_info=True,
+ )
+ await self.end_session()
+ self.connection_state = ThermostatConnectionState.DISCONNECTED
+ self.connection_error.emit()
+ return
+ self._update_params_task = asyncio.create_task(self.update_params())
+ await asyncio.sleep(self._update_s)
+
+ async def update_params(self):
+ self.fan, self.pwm, self.report, self.pid, self.thermistor, self.postfilter = (
+ await asyncio.gather(
+ self._client.get_fan(),
+ self._client.get_pwm(),
+ self._client.report(),
+ self._client.get_pid(),
+ self._client.get_steinhart_hart(),
+ self._client.get_postfilter(),
+ )
+ )
+
+ def connected(self):
+ return self._client.connected()
+
+ @pyqtSlot(float)
+ def set_update_s(self, update_s):
+ self._update_s = update_s
+
+ async def set_ipv4(self, ipv4):
+ await self._client.set_param("ipv4", ipv4)
+
+ async def get_ipv4(self):
+ return await self._client.ipv4()
+
+ @asyncSlot()
+ async def save_cfg(self, ch=""):
+ await self._client.save_config(ch)
+
+ @asyncSlot()
+ async def load_cfg(self, ch=""):
+ await self._client.load_config(ch)
+
+ async def dfu(self):
+ await self._client.dfu()
+
+ async def reset(self):
+ await self._client.reset()
+
+ async def set_fan(self, power="auto"):
+ await self._client.set_fan(power)
+
+ async def get_fan(self):
+ return await self._client.get_fan()
+
+ async def set_param(self, topic, channel, field="", value=""):
+ await self._client.set_param(topic, channel, field, value)
diff --git a/pytec/pytec/gui/resources/artiq.ico b/pytec/pytec/gui/resources/artiq.ico
new file mode 100644
index 0000000..edb222d
Binary files /dev/null and b/pytec/pytec/gui/resources/artiq.ico differ
diff --git a/pytec/pytec/gui/view/connection_details_menu.py b/pytec/pytec/gui/view/connection_details_menu.py
new file mode 100644
index 0000000..3f321da
--- /dev/null
+++ b/pytec/pytec/gui/view/connection_details_menu.py
@@ -0,0 +1,73 @@
+from PyQt6 import QtWidgets, QtCore
+from PyQt6.QtCore import pyqtSlot
+from pytec.gui.model.thermostat import ThermostatConnectionState
+
+
+class ConnectionDetailsMenu(QtWidgets.QMenu):
+ def __init__(self, thermostat, connect_btn):
+ super().__init__()
+ self._thermostat = thermostat
+ self._connect_btn = connect_btn
+ self._thermostat.connection_state_update.connect(
+ self.thermostat_state_change_handler
+ )
+
+ self.setTitle("Connection Settings")
+
+ self.host_set_line = QtWidgets.QLineEdit()
+ self.host_set_line.setMinimumSize(QtCore.QSize(160, 0))
+ self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215))
+ self.host_set_line.setMaxLength(15)
+ self.host_set_line.setClearButtonEnabled(True)
+
+ def connect_on_enter_press():
+ self._connect_btn.click()
+ self.hide()
+
+ self.host_set_line.returnPressed.connect(connect_on_enter_press)
+
+ self.host_set_line.setText("192.168.1.26")
+ self.host_set_line.setPlaceholderText("IP for the Thermostat")
+
+ host = QtWidgets.QWidgetAction(self)
+ host.setDefaultWidget(self.host_set_line)
+ self.addAction(host)
+ self.host = host
+
+ self.port_set_spin = QtWidgets.QSpinBox()
+ self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0))
+ self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215))
+ self.port_set_spin.setMaximum(65535)
+ self.port_set_spin.setValue(23)
+
+ def connect_only_if_enter_pressed():
+ if (
+ not self.port_set_spin.hasFocus()
+ ): # Don't connect if the spinbox only lost focus
+ return
+ connect_on_enter_press()
+
+ self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
+
+ port = QtWidgets.QWidgetAction(self)
+ port.setDefaultWidget(self.port_set_spin)
+ self.addAction(port)
+ self.port = port
+
+ self.exit_button = QtWidgets.QPushButton()
+ self.exit_button.setText("Exit GUI")
+ self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
+
+ exit_action = QtWidgets.QWidgetAction(self.exit_button)
+ exit_action.setDefaultWidget(self.exit_button)
+ self.addAction(exit_action)
+ self.exit_action = exit_action
+
+ @pyqtSlot(ThermostatConnectionState)
+ def thermostat_state_change_handler(self, state):
+ self.host_set_line.setEnabled(
+ state == ThermostatConnectionState.DISCONNECTED
+ )
+ self.port_set_spin.setEnabled(
+ state == ThermostatConnectionState.DISCONNECTED
+ )
diff --git a/pytec/pytec/gui/view/ctrl_panel.py b/pytec/pytec/gui/view/ctrl_panel.py
new file mode 100644
index 0000000..b3a9294
--- /dev/null
+++ b/pytec/pytec/gui/view/ctrl_panel.py
@@ -0,0 +1,307 @@
+from functools import partial
+from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
+import pyqtgraph.parametertree.parameterTypes as pTypes
+from pyqtgraph.parametertree import (
+ Parameter,
+ registerParameterType,
+)
+from qasync import asyncSlot
+from autotune import PIDAutotuneState
+
+
+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 CtrlPanel(QObject):
+ def __init__(
+ self,
+ thermostat,
+ autotuners,
+ info_box,
+ trees_ui,
+ param_tree,
+ parent=None,
+ ):
+ super().__init__(parent)
+
+ self.thermostat = thermostat
+ self.autotuners = autotuners
+ self.info_box = info_box
+ self.trees_ui = trees_ui
+ self.NUM_CHANNELS = len(trees_ui)
+
+ self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)]
+
+ self.params = [
+ Parameter.create(
+ name=f"Thermostat Channel {ch} Parameters",
+ type="group",
+ value=ch,
+ children=self.THERMOSTAT_PARAMETERS[ch],
+ )
+ for ch in range(self.NUM_CHANNELS)
+ ]
+
+ for i, param in enumerate(self.params):
+ param.channel = i
+
+ for i, tree in enumerate(self.trees_ui):
+ tree.setHeaderHidden(True)
+ tree.setParameters(self.params[i], showTop=False)
+ self.params[i].setValue = self._setValue
+ self.params[i].sigTreeStateChanged.connect(self.send_command)
+
+ self.params[i].child("Save to flash").sigActivated.connect(
+ partial(self.save_settings, i)
+ )
+ self.params[i].child("Load from flash").sigActivated.connect(
+ partial(self.load_settings, i)
+ )
+ self.params[i].child(
+ "PID Config", "PID Auto Tune", "Run"
+ ).sigActivated.connect(partial(self.pid_auto_tune_request, i))
+
+ self.thermostat.pid_update.connect(self.update_pid)
+ self.thermostat.report_update.connect(self.update_report)
+ self.thermostat.thermistor_update.connect(self.update_thermistor)
+ self.thermostat.pwm_update.connect(self.update_pwm)
+ self.thermostat.postfilter_update.connect(self.update_postfilter)
+ self.autotuners.autotune_state_changed.connect(self.update_pid_autotune)
+
+ def _setValue(self, value, blockSignal=None):
+ """
+ Implement 'lock' mechanism for Parameter Type
+
+ Modified from the source
+ """
+ try:
+ if blockSignal is not None:
+ self.sigValueChanged.disconnect(blockSignal)
+ value = self._interpretValue(value)
+ if fn.eq(self.opts["value"], value):
+ return value
+
+ if "lock" in self.opts.keys():
+ if self.opts["lock"]:
+ return value
+ self.opts["value"] = value
+ self.sigValueChanged.emit(
+ self, value
+ ) # value might change after signal is received by tree item
+ finally:
+ if blockSignal is not None:
+ self.sigValueChanged.connect(blockSignal)
+
+ return self.opts["value"]
+
+ def change_params_title(self, channel, path, title):
+ self.params[channel].child(*path).setOpts(title=title)
+
+ @asyncSlot(object, object)
+ async def send_command(self, param, changes):
+ """Translates parameter tree changes into thermostat set_param calls"""
+ ch = param.channel
+
+ for inner_param, change, data in changes:
+ if change == "value":
+ new_value = data
+ if "thermostat:set_param" in inner_param.opts:
+ if inner_param.opts.get("suffix", None) == "mA":
+ new_value /= 1000 # Given in mA
+
+ thermostat_param = inner_param.opts["thermostat:set_param"]
+
+ # Handle thermostat command irregularities
+ match inner_param.name(), new_value:
+ case "Postfilter Rate", None:
+ thermostat_param = thermostat_param.copy()
+ thermostat_param["field"] = "off"
+ new_value = ""
+ case "Control Method", "Constant Current":
+ return
+ case "Control Method", "Temperature PID":
+ new_value = ""
+
+ inner_param.setOpts(lock=True)
+ await self.thermostat.set_param(
+ channel=ch, value=new_value, **thermostat_param
+ )
+ inner_param.setOpts(lock=False)
+
+ if "pid_autotune" in inner_param.opts:
+ auto_tuner_param = inner_param.opts["pid_autotune"]
+ self.autotuners.set_params(auto_tuner_param, ch, new_value)
+
+ @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"])
+
+ @pyqtSlot(list)
+ def update_report(self, report_data):
+ for settings in report_data:
+ channel = settings["channel"]
+ with QSignalBlocker(self.params[channel]):
+ self.params[channel].child("Output Config", "Control Method").setValue(
+ "Temperature PID" if settings["pid_engaged"] else "Constant Current"
+ )
+ self.params[channel].child(
+ "Output Config", "Control Method", "Set Current"
+ ).setValue(settings["i_set"] * 1000)
+ if settings["temperature"] is not None:
+ self.params[channel].child("Temperature").setValue(
+ settings["temperature"]
+ )
+ if settings["tec_i"] is not None:
+ self.params[channel].child("Current through TEC").setValue(
+ settings["tec_i"] * 1000
+ )
+
+ @pyqtSlot(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):
+ 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)
+
+ @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"])
+
+ def update_pid_autotune(self, ch, state):
+ match state:
+ case PIDAutotuneState.STATE_OFF:
+ self.change_params_title(
+ ch, ("PID Config", "PID Auto Tune", "Run"), "Run"
+ )
+ case (
+ PIDAutotuneState.STATE_READY
+ | PIDAutotuneState.STATE_RELAY_STEP_UP
+ | PIDAutotuneState.STATE_RELAY_STEP_DOWN
+ ):
+ self.change_params_title(
+ ch, ("PID Config", "PID Auto Tune", "Run"), "Stop"
+ )
+ case PIDAutotuneState.STATE_SUCCEEDED:
+ self.info_box.display_info_box(
+ "PID Autotune Success",
+ f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.",
+ )
+ case PIDAutotuneState.STATE_FAILED:
+ self.info_box.display_info_box(
+ "PID Autotune Failed",
+ f"Channel {ch} PID Autotune has failed.",
+ )
+
+ @asyncSlot(int)
+ async def load_settings(self, ch):
+ await self.thermostat.load_cfg(ch)
+
+ self.info_box.display_info_box(
+ f"Channel {ch} settings loaded",
+ f"Channel {ch} settings has been loaded from flash.",
+ )
+
+ @asyncSlot(int)
+ async def save_settings(self, ch):
+ await self.thermostat.save_cfg(ch)
+
+ self.info_box.display_info_box(
+ f"Channel {ch} settings saved",
+ f"Channel {ch} settings has been saved to flash.\n"
+ "It will be loaded on Thermostat reset, or when settings are explicitly loaded.",
+ )
+
+ @asyncSlot()
+ async def pid_auto_tune_request(self, ch=0):
+ match self.autotuners.get_state(ch):
+ case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED:
+ self.autotuners.load_params_and_set_ready(ch)
+
+ case (
+ PIDAutotuneState.STATE_READY
+ | PIDAutotuneState.STATE_RELAY_STEP_UP
+ | PIDAutotuneState.STATE_RELAY_STEP_DOWN
+ ):
+ await self.autotuners.stop_pid_from_running(ch)
+
diff --git a/pytec/pytec/gui/view/info_box.py b/pytec/pytec/gui/view/info_box.py
new file mode 100644
index 0000000..3d6b7bf
--- /dev/null
+++ b/pytec/pytec/gui/view/info_box.py
@@ -0,0 +1,14 @@
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import pyqtSlot
+
+
+class InfoBox(QtWidgets.QMessageBox):
+ def __init__(self):
+ super().__init__()
+ self.setIcon(QtWidgets.QMessageBox.Icon.Information)
+
+ @pyqtSlot(str, str)
+ def display_info_box(self, title, text):
+ self.setWindowTitle(title)
+ self.setText(text)
+ self.show()
diff --git a/pytec/pytec/gui/view/live_plot_view.py b/pytec/pytec/gui/view/live_plot_view.py
new file mode 100644
index 0000000..c2656f7
--- /dev/null
+++ b/pytec/pytec/gui/view/live_plot_view.py
@@ -0,0 +1,180 @@
+from PyQt6.QtCore import QObject, pyqtSlot
+from pglive.sources.data_connector import DataConnector
+from pglive.kwargs import Axis
+from pglive.sources.live_plot import LiveLinePlot
+from pglive.sources.live_axis import LiveAxis
+from collections import deque
+import pyqtgraph as pg
+from pytec.gui.model.thermostat import ThermostatConnectionState
+
+pg.setConfigOptions(antialias=True)
+
+
+class LiveDataPlotter(QObject):
+ def __init__(self, thermostat, live_plots):
+ super().__init__()
+ self._thermostat = thermostat
+
+ self._thermostat.report_update.connect(self.update_report)
+ self._thermostat.pid_update.connect(self.update_pid)
+ self._thermostat.connection_state_update.connect(
+ self.thermostat_state_change_handler
+ )
+
+ self.NUM_CHANNELS = len(live_plots)
+ self.graphs = []
+
+ for i, live_plot in enumerate(live_plots):
+ live_plot[0].setTitle(f"Channel {i} Temperature")
+ live_plot[1].setTitle(f"Channel {i} Current")
+ self.graphs.append(_TecGraphs(live_plot[0], live_plot[1]))
+
+ @pyqtSlot(ThermostatConnectionState)
+ def thermostat_state_change_handler(self, state):
+ if state == ThermostatConnectionState.DISCONNECTED:
+ self.clear_graphs()
+
+ def _config_connector_max_pts(self, connector, samples):
+ connector.max_points = samples
+ connector.x = deque(maxlen=int(connector.max_points))
+ connector.y = deque(maxlen=int(connector.max_points))
+
+ @pyqtSlot(int)
+ def set_max_samples(self, samples: int):
+ for graph in self.graphs:
+ self._config_connector_max_pts(graph.t_connector, samples)
+ self._config_connector_max_pts(graph.i_connector, samples)
+ self._config_connector_max_pts(graph.iset_connector, samples)
+
+ @pyqtSlot()
+ def clear_graphs(self):
+ for graph in self.graphs:
+ graph.clear()
+
+ @pyqtSlot(list)
+ def update_pid(self, pid_settings):
+ for settings in pid_settings:
+ channel = settings["channel"]
+ self.graphs[channel].update_pid(settings)
+
+ @pyqtSlot(list)
+ def update_report(self, report_data):
+ for settings in report_data:
+ channel = settings["channel"]
+ self.graphs[channel].update_report(settings)
+
+
+class _TecGraphs:
+ """The maximum number of sample points to store."""
+
+ DEFAULT_MAX_SAMPLES = 1000
+
+ def __init__(self, t_widget, i_widget):
+ self._t_widget = t_widget
+ self._i_widget = i_widget
+
+ self._t_plot = LiveLinePlot()
+ self._i_plot = LiveLinePlot(name="Measured")
+ self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen("r"))
+
+ self._t_line = self._t_widget.getPlotItem().addLine(label="{value} °C")
+ self._t_line.setVisible(False)
+ # Hack for keeping setpoint line in plot range
+ self._t_setpoint_plot = LiveLinePlot()
+
+ for graph in t_widget, i_widget:
+ time_axis = LiveAxis(
+ "bottom",
+ text="Time since Thermostat reset",
+ **{Axis.TICK_FORMAT: Axis.DURATION},
+ )
+ time_axis.showLabel()
+ graph.setAxisItems({"bottom": time_axis})
+
+ graph.add_crosshair(pg.mkPen(color="red", width=1), {"color": "green"})
+
+ # Enable linking of axes in the graph widget's context menu
+ graph.register(
+ graph.getPlotItem().titleLabel.text # Slight hack getting the title
+ )
+
+ temperature_axis = LiveAxis("left", text="Temperature", units="°C")
+ temperature_axis.showLabel()
+ t_widget.setAxisItems({"left": temperature_axis})
+
+ current_axis = LiveAxis("left", text="Current", units="A")
+ current_axis.showLabel()
+ i_widget.setAxisItems({"left": current_axis})
+ i_widget.addLegend(brush=(50, 50, 200, 150))
+
+ t_widget.addItem(self._t_plot)
+ t_widget.addItem(self._t_setpoint_plot)
+ i_widget.addItem(self._i_plot)
+ i_widget.addItem(self._iset_plot)
+
+ self.t_connector = DataConnector(
+ self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES
+ )
+ self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1)
+ self.i_connector = DataConnector(
+ self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES
+ )
+ self.iset_connector = DataConnector(
+ self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES
+ )
+
+ self.max_samples = self.DEFAULT_MAX_SAMPLES
+
+ def plot_append(self, report):
+ temperature = report["temperature"]
+ current = report["tec_i"]
+ iset = report["i_set"]
+ time = report["time"]
+
+ if temperature is not None:
+ self.t_connector.cb_append_data_point(temperature, time)
+ if self._t_line.isVisible():
+ self.t_setpoint_connector.cb_append_data_point(
+ self._t_line.value(), time
+ )
+ else:
+ self.t_setpoint_connector.cb_append_data_point(temperature, time)
+ if current is not None:
+ self.i_connector.cb_append_data_point(current, time)
+ self.iset_connector.cb_append_data_point(iset, time)
+
+ def set_max_sample(self, samples: int):
+ for connector in self.t_connector, self.i_connector, self.iset_connector:
+ connector.max_points(samples)
+
+ def clear(self):
+ for connector in self.t_connector, self.i_connector, self.iset_connector:
+ connector.clear()
+
+ def set_t_line(self, temp=None, visible=None):
+ if visible is not None:
+ self._t_line.setVisible(visible)
+ if temp is not None:
+ self._t_line.setValue(temp)
+
+ # PyQtGraph normally does not update this text when the line
+ # is not visible, so make sure that the temperature label
+ # gets updated always, and doesn't stay at an old value.
+ self._t_line.label.setText(f"{temp} °C")
+
+ def set_max_samples(self, samples: int):
+ for graph in self.graphs:
+ graph.t_connector.max_points = samples
+ graph.i_connector.max_points = samples
+ graph.iset_connector.max_points = samples
+
+ def clear_graphs(self):
+ for graph in self.graphs:
+ graph.clear()
+
+ def update_pid(self, pid_settings):
+ self.set_t_line(temp=round(pid_settings["target"], 6))
+
+ def update_report(self, report_data):
+ self.plot_append(report_data)
+ self.set_t_line(visible=report_data["pid_engaged"])
diff --git a/pytec/pytec/gui/view/net_settings_input_diag.py b/pytec/pytec/gui/view/net_settings_input_diag.py
new file mode 100644
index 0000000..1ef4f61
--- /dev/null
+++ b/pytec/pytec/gui/view/net_settings_input_diag.py
@@ -0,0 +1,36 @@
+from PyQt6 import QtWidgets
+from PyQt6.QtWidgets import QAbstractButton
+from PyQt6.QtCore import pyqtSignal, pyqtSlot
+
+
+class NetSettingsInputDiag(QtWidgets.QInputDialog):
+ set_ipv4_act = pyqtSignal(str)
+
+ def __init__(self, current_ipv4_settings):
+ super().__init__()
+ self.setWindowTitle("Network Settings")
+ self.setLabelText(
+ "Set the Thermostat's IPv4 address, netmask and gateway (optional)"
+ )
+ self.setTextValue(current_ipv4_settings)
+ self._new_ipv4 = ""
+
+ @pyqtSlot(str)
+ def set_ipv4(ipv4_settings):
+ self._new_ipv4 = ipv4_settings
+
+ sure = QtWidgets.QMessageBox(self)
+ sure.setWindowTitle("Set network?")
+ sure.setText(
+ f"Setting this as network and disconnecting:
{ipv4_settings}"
+ )
+
+ sure.buttonClicked.connect(self._emit_sig)
+ sure.show()
+
+ self.textValueSelected.connect(set_ipv4)
+ self.show()
+
+ @pyqtSlot(QAbstractButton)
+ def _emit_sig(self, _):
+ self.set_ipv4_act.emit(self._new_ipv4)
diff --git a/pytec/pytec/gui/view/param_tree.json b/pytec/pytec/gui/view/param_tree.json
new file mode 100644
index 0000000..28ce704
--- /dev/null
+++ b/pytec/pytec/gui/view/param_tree.json
@@ -0,0 +1,335 @@
+{
+ "ctrl_panel":[
+ {
+ "name":"Temperature",
+ "type":"float",
+ "format":"{value:.4f} °C",
+ "readonly":true
+ },
+ {
+ "name":"Current through TEC",
+ "type":"float",
+ "suffix":"mA",
+ "decimals":6,
+ "readonly":true
+ },
+ {
+ "name":"Output Config",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"Control Method",
+ "type":"mutex",
+ "limits":[
+ "Constant Current",
+ "Temperature PID"
+ ],
+ "thermostat:set_param":{
+ "topic":"pwm",
+ "field":"pid"
+ },
+ "children":[
+ {
+ "name":"Set Current",
+ "type":"float",
+ "value":0,
+ "step":100,
+ "limits":[
+ -2000,
+ 2000
+ ],
+ "triggerOnShow":true,
+ "decimals":6,
+ "suffix":"mA",
+ "thermostat:set_param":{
+ "topic":"pwm",
+ "field":"i_set"
+ },
+ "lock":false
+ },
+ {
+ "name":"Set Temperature",
+ "type":"float",
+ "value":25,
+ "step":0.1,
+ "limits":[
+ -273,
+ 300
+ ],
+ "format":"{value:.4f} °C",
+ "thermostat:set_param":{
+ "topic":"pid",
+ "field":"target"
+ },
+ "lock":false
+ }
+ ]
+ },
+ {
+ "name":"Limits",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"Max Cooling Current",
+ "type":"float",
+ "value":0,
+ "step":100,
+ "decimals":6,
+ "limits":[
+ 0,
+ 2000
+ ],
+ "suffix":"mA",
+ "thermostat:set_param":{
+ "topic":"pwm",
+ "field":"max_i_pos"
+ },
+ "lock":false
+ },
+ {
+ "name":"Max Heating Current",
+ "type":"float",
+ "value":0,
+ "step":100,
+ "decimals":6,
+ "limits":[
+ 0,
+ 2000
+ ],
+ "suffix":"mA",
+ "thermostat:set_param":{
+ "topic":"pwm",
+ "field":"max_i_neg"
+ },
+ "lock":false
+ },
+ {
+ "name":"Max Voltage Difference",
+ "type":"float",
+ "value":0,
+ "step":0.1,
+ "limits":[
+ 0,
+ 5
+ ],
+ "siPrefix":true,
+ "suffix":"V",
+ "thermostat:set_param":{
+ "topic":"pwm",
+ "field":"max_v"
+ },
+ "lock":false
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name":"Thermistor Config",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"T₀",
+ "type":"float",
+ "value":25,
+ "step":0.1,
+ "limits":[
+ -100,
+ 100
+ ],
+ "format":"{value:.4f} °C",
+ "thermostat:set_param":{
+ "topic":"s-h",
+ "field":"t0"
+ },
+ "lock":false
+ },
+ {
+ "name":"R₀",
+ "type":"float",
+ "value":10000,
+ "step":1,
+ "siPrefix":true,
+ "suffix":"Ω",
+ "thermostat:set_param":{
+ "topic":"s-h",
+ "field":"r0"
+ },
+ "lock":false
+ },
+ {
+ "name":"B",
+ "type":"float",
+ "value":3950,
+ "step":1,
+ "suffix":"K",
+ "decimals":4,
+ "thermostat:set_param":{
+ "topic":"s-h",
+ "field":"b"
+ },
+ "lock":false
+ },
+ {
+ "name":"Postfilter Rate",
+ "type":"list",
+ "value":16.67,
+ "thermostat:set_param":{
+ "topic":"postfilter",
+ "field":"rate"
+ },
+ "limits":{
+ "Off":null,
+ "16.67 Hz":16.67,
+ "20 Hz":20.0,
+ "21.25 Hz":21.25,
+ "27 Hz":27.0
+ },
+ "lock":false
+ }
+ ]
+ },
+ {
+ "name":"PID Config",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"Kp",
+ "type":"float",
+ "step":0.1,
+ "suffix":"",
+ "thermostat:set_param":{
+ "topic":"pid",
+ "field":"kp"
+ },
+ "lock":false
+ },
+ {
+ "name":"Ki",
+ "type":"float",
+ "step":0.1,
+ "suffix":"Hz",
+ "thermostat:set_param":{
+ "topic":"pid",
+ "field":"ki"
+ },
+ "lock":false
+ },
+ {
+ "name":"Kd",
+ "type":"float",
+ "step":0.1,
+ "suffix":"s",
+ "thermostat:set_param":{
+ "topic":"pid",
+ "field":"kd"
+ },
+ "lock":false
+ },
+ {
+ "name":"PID Output Clamping",
+ "expanded":true,
+ "type":"group",
+ "children":[
+ {
+ "name":"Minimum",
+ "type":"float",
+ "step":100,
+ "limits":[
+ -2000,
+ 2000
+ ],
+ "decimals":6,
+ "suffix":"mA",
+ "thermostat:set_param":{
+ "topic":"pid",
+ "field":"output_min"
+ },
+ "lock":false
+ },
+ {
+ "name":"Maximum",
+ "type":"float",
+ "step":100,
+ "limits":[
+ -2000,
+ 2000
+ ],
+ "decimals":6,
+ "suffix":"mA",
+ "thermostat:set_param":{
+ "topic":"pid",
+ "field":"output_max"
+ },
+ "lock":false
+ }
+ ]
+ },
+ {
+ "name":"PID Auto Tune",
+ "expanded":false,
+ "type":"group",
+ "children":[
+ {
+ "name":"Target Temperature",
+ "type":"float",
+ "value":20,
+ "step":0.1,
+ "format":"{value:.4f} °C",
+ "pid_autotune":"target_temp"
+ },
+ {
+ "name":"Test Current",
+ "type":"float",
+ "value":0,
+ "decimals":6,
+ "step":100,
+ "limits":[
+ -2000,
+ 2000
+ ],
+ "suffix":"mA",
+ "pid_autotune":"test_current"
+ },
+ {
+ "name":"Temperature Swing",
+ "type":"float",
+ "value":1.5,
+ "step":0.1,
+ "prefix":"±",
+ "format":"{value:.4f} °C",
+ "pid_autotune":"temp_swing"
+ },
+ {
+ "name":"Lookback",
+ "type":"float",
+ "value":3.0,
+ "step":0.1,
+ "format":"{value:.4f} s",
+ "pid_autotune":"lookback"
+ },
+ {
+ "name":"Run",
+ "type":"action",
+ "tip":"Run"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name":"Save to flash",
+ "type":"action",
+ "tip":"Save config to thermostat, applies on reset"
+ },
+ {
+ "name":"Load from flash",
+ "type":"action",
+ "tip":"Load config from flash"
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/pytec/pytec/gui/view/plot_options_menu.py b/pytec/pytec/gui/view/plot_options_menu.py
new file mode 100644
index 0000000..8ec6f76
--- /dev/null
+++ b/pytec/pytec/gui/view/plot_options_menu.py
@@ -0,0 +1,25 @@
+from PyQt6 import QtWidgets, QtGui
+
+
+class PlotOptionsMenu(QtWidgets.QMenu):
+ def __init__(self, channel_graphs, max_samples=1000):
+ super().__init__()
+ self.channel_graphs = channel_graphs
+
+ self.setTitle("Plot Settings")
+
+ clear = QtGui.QAction("Clear graphs", self)
+ self.addAction(clear)
+ self.clear = clear
+ self.clear.triggered.connect(self.channel_graphs.clear_graphs)
+
+ self.samples_spinbox = QtWidgets.QSpinBox()
+ self.samples_spinbox.setRange(2, 100000)
+ self.samples_spinbox.setSuffix(" samples")
+ self.samples_spinbox.setValue(max_samples)
+ self.samples_spinbox.valueChanged.connect(self.channel_graphs.set_max_samples)
+
+ limit_samples = QtWidgets.QWidgetAction(self)
+ limit_samples.setDefaultWidget(self.samples_spinbox)
+ self.addAction(limit_samples)
+ self.limit_samples = limit_samples
diff --git a/pytec/pytec/gui/view/tec_qt.ui b/pytec/pytec/gui/view/tec_qt.ui
new file mode 100644
index 0000000..6c7f6c6
--- /dev/null
+++ b/pytec/pytec/gui/view/tec_qt.ui
@@ -0,0 +1,572 @@
+
+