diff --git a/README.md b/README.md
index 3e37ccf..7507570 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,18 @@ 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 pythermostat/pythermostat/thermostat_qt.py, and 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.nix b/flake.nix
index eafafa2..5772ba1 100644
--- a/flake.nix
+++ b/flake.nix
@@ -64,11 +64,37 @@
format = "pyproject";
src = "${self}/pythermostat";
+ nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
propagatedBuildInputs =
- with pkgs.python3Packages; [
+ [ pkgs.qt6.qtbase ]
+ ++ (with pkgs.python3Packages; [
numpy
matplotlib
- ];
+ pyqtgraph
+ pyqt6
+ qasync
+ pglive
+ ]);
+
+ dontWrapQtApps = true;
+ postFixup = ''
+ wrapQtApp "$out/bin/thermostat_qt"
+ '';
+ };
+
+ 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
+ ];
};
in
{
@@ -77,6 +103,11 @@
default = thermostat;
};
+ apps.x86_64-linux.thermostat_gui = {
+ type = "app";
+ program = "${self.packages.x86_64-linux.pythermostat}/bin/thermostat_qt";
+ };
+
hydraJobs = {
inherit thermostat;
};
@@ -95,7 +126,15 @@
++ (with python3Packages; [
numpy
matplotlib
+ pyqtgraph
+ pyqt6
+ qasync
+ pglive
+ pythermostat
]);
+ shellHook = ''
+ export PYTHONPATH=`git rev-parse --show-toplevel`/pythermostat:$PYTHONPATH
+ '';
};
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-rfc-style;
diff --git a/pythermostat/MANIFEST.in b/pythermostat/MANIFEST.in
new file mode 100644
index 0000000..26af249
--- /dev/null
+++ b/pythermostat/MANIFEST.in
@@ -0,0 +1,4 @@
+graft examples
+include pythermostat/gui/resources/artiq.ico
+include pythermostat/gui/view/param_tree.json
+include pythermostat/gui/view/tec_qt.ui
diff --git a/pythermostat/examples/aioexample.py b/pythermostat/examples/aioexample.py
new file mode 100644
index 0000000..8f7b6a6
--- /dev/null
+++ b/pythermostat/examples/aioexample.py
@@ -0,0 +1,36 @@
+import asyncio
+from contextlib import suppress
+from pythermostat.aioclient import AsyncioClient
+
+
+async def poll_for_info(tec):
+ while True:
+ print(tec.get_output())
+ print(tec.get_b_parameter())
+ 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("b-p", 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/pythermostat/example.py b/pythermostat/examples/example.py
similarity index 100%
rename from pythermostat/example.py
rename to pythermostat/examples/example.py
diff --git a/pythermostat/pyproject.toml b/pythermostat/pyproject.toml
index 5e37137..7dc8576 100644
--- a/pythermostat/pyproject.toml
+++ b/pythermostat/pyproject.toml
@@ -12,6 +12,7 @@ license = {text = "GPLv3"}
[project.gui-scripts]
thermostat_plot = "pythermostat.plot:main"
+thermostat_qt = "pythermostat.thermostat_qt:main"
[project.scripts]
thermostat_autotune = "pythermostat.autotune:main"
diff --git a/pythermostat/pythermostat/aioclient.py b/pythermostat/pythermostat/aioclient.py
new file mode 100644
index 0000000..31c3a79
--- /dev/null
+++ b/pythermostat/pythermostat/aioclient.py
@@ -0,0 +1,240 @@
+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()
+
+ 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_report(self):
+ """Obtain one-time report on measurement values
+
+ Example of yielded data:
+ {'channel': 0,
+ 'time': 2302524,
+ 'interval': 0.12
+ 'adc': 0.6199188965423515,
+ 'sens': 6138.519310282602,
+ 'temperature': 36.87032392655527,
+ 'pid_engaged': True,
+ 'i_set': 2.0635816680889123,
+ '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}
+ """
+ return await self._command("report")
+
+ async def get_ipv4(self):
+ """Get the IPv4 settings of the Thermostat"""
+ return await self._command("ipv4")
+
+ async def get_fan(self):
+ """Get Thermostat current fan settings"""
+ return await self._command("fan")
+
+ async def get_hwrev(self):
+ """Get Thermostat hardware revision"""
+ return await self._command("hwrev")
+
+ 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 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 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 enter_dfu_mode(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 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))
diff --git a/pythermostat/pythermostat/autotune.py b/pythermostat/pythermostat/autotune.py
index dada469..7272952 100644
--- a/pythermostat/pythermostat/autotune.py
+++ b/pythermostat/pythermostat/autotune.py
@@ -18,6 +18,7 @@ class PIDAutotuneState(Enum):
STATE_RELAY_STEP_DOWN = 'relay step down'
STATE_SUCCEEDED = 'succeeded'
STATE_FAILED = 'failed'
+ STATE_READY = "ready"
class PIDAutotune:
@@ -57,6 +58,21 @@ 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
+ self._peak_count = 0
+
+ def setOff(self):
+ self._state = PIDAutotuneState.STATE_OFF
+
def state(self):
"""Get the current state."""
return self._state
@@ -82,6 +98,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.
diff --git a/pythermostat/pythermostat/gui/model/pid_autotuner.py b/pythermostat/pythermostat/gui/model/pid_autotuner.py
new file mode 100644
index 0000000..c916a21
--- /dev/null
+++ b/pythermostat/pythermostat/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("output", 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(
+ "output", 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("output", 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("output", ch, "i_set", 0)
diff --git a/pythermostat/pythermostat/gui/model/property.py b/pythermostat/pythermostat/gui/model/property.py
new file mode 100644
index 0000000..badea1c
--- /dev/null
+++ b/pythermostat/pythermostat/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/pythermostat/pythermostat/gui/model/thermostat.py b/pythermostat/pythermostat/gui/model/thermostat.py
new file mode 100644
index 0000000..c307546
--- /dev/null
+++ b/pythermostat/pythermostat/gui/model/thermostat.py
@@ -0,0 +1,135 @@
+import asyncio
+import logging
+from enum import Enum
+from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
+from qasync import asyncSlot
+from pythermostat.aioclient import AsyncioClient
+from pythermostat.gui.model.property import Property, PropertyMeta
+
+
+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)
+ output = 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.get_hwrev()
+
+ @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.output,
+ self.report,
+ self.pid,
+ self.thermistor,
+ self.postfilter,
+ ) = await asyncio.gather(
+ self._client.get_fan(),
+ self._client.get_output(),
+ self._client.get_report(),
+ self._client.get_pid(),
+ self._client.get_b_parameter(),
+ 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.get_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.enter_dfu_mode()
+
+ 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/pythermostat/pythermostat/gui/resources/artiq.ico b/pythermostat/pythermostat/gui/resources/artiq.ico
new file mode 100644
index 0000000..edb222d
Binary files /dev/null and b/pythermostat/pythermostat/gui/resources/artiq.ico differ
diff --git a/pythermostat/pythermostat/gui/view/MainWindow.ui b/pythermostat/pythermostat/gui/view/MainWindow.ui
new file mode 100644
index 0000000..3d61e3d
--- /dev/null
+++ b/pythermostat/pythermostat/gui/view/MainWindow.ui
@@ -0,0 +1,572 @@
+
+
{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/pythermostat/pythermostat/gui/view/param_tree.json b/pythermostat/pythermostat/gui/view/param_tree.json
new file mode 100644
index 0000000..03da4b9
--- /dev/null
+++ b/pythermostat/pythermostat/gui/view/param_tree.json
@@ -0,0 +1,336 @@
+{
+ "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"
+ ],
+ "value": "Constant Current",
+ "thermostat:set_param": {
+ "topic": "output",
+ "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": "output",
+ "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": "output",
+ "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": "output",
+ "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": "output",
+ "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/pythermostat/pythermostat/gui/view/plot_options_menu.py b/pythermostat/pythermostat/gui/view/plot_options_menu.py
new file mode 100644
index 0000000..8ec6f76
--- /dev/null
+++ b/pythermostat/pythermostat/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/pythermostat/pythermostat/gui/view/thermostat_settings_menu.py b/pythermostat/pythermostat/gui/view/thermostat_settings_menu.py
new file mode 100644
index 0000000..09555e0
--- /dev/null
+++ b/pythermostat/pythermostat/gui/view/thermostat_settings_menu.py
@@ -0,0 +1,215 @@
+import logging
+from PyQt6 import QtWidgets, QtGui, QtCore
+from PyQt6.QtCore import pyqtSignal, pyqtSlot, QSignalBlocker
+from qasync import asyncSlot
+from pythermostat.gui.view.net_settings_input_diag import NetSettingsInputDiag
+from pythermostat.gui.model.thermostat import ThermostatConnectionState
+
+
+class ThermostatSettingsMenu(QtWidgets.QMenu):
+ def __init__(self, thermostat, info_box, style):
+ super().__init__()
+ self._thermostat = thermostat
+ self._info_box = info_box
+ self._style = style
+ self.setTitle("Thermostat settings")
+
+ self.hw_rev_data = dict()
+ self._thermostat.hw_rev_update.connect(self.hw_rev)
+ self._thermostat.connection_state_update.connect(
+ self.thermostat_state_change_handler
+ )
+
+ 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_request)
+ self.fan_auto_box.stateChanged.connect(self.fan_auto_set_request)
+ self._thermostat.fan_update.connect(self.fan_update)
+
+ self.fan_lbl.setToolTip("Adjust the fan")
+ self.fan_lbl.setText("Fan:")
+ self.fan_auto_box.setText("Auto")
+
+ fan = QtWidgets.QWidgetAction(self)
+ fan.setDefaultWidget(self.fan_group)
+ self.addAction(fan)
+ self.fan = fan
+
+ self.actionReset = QtGui.QAction("Reset Thermostat", self)
+ self.actionReset.triggered.connect(self.reset_request)
+ self.addAction(self.actionReset)
+
+ self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self)
+ self.actionEnter_DFU_Mode.triggered.connect(self.dfu_request)
+ self.addAction(self.actionEnter_DFU_Mode)
+
+ self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self)
+ self.actionnet_settings_input_diag.triggered.connect(self.net_settings_request)
+ self.addAction(self.actionnet_settings_input_diag)
+
+ @asyncSlot(bool)
+ async def load(_):
+ await self._thermostat.load_cfg()
+
+ self._info_box.display_info_box(
+ "Config loaded", "All channel configs have been loaded from flash."
+ )
+
+ self.actionLoad_all_configs = QtGui.QAction("Load Config", self)
+ self.actionLoad_all_configs.triggered.connect(load)
+ self.addAction(self.actionLoad_all_configs)
+
+ @asyncSlot(bool)
+ async def save(_):
+ await self._thermostat.save_cfg()
+
+ self._info_box.display_info_box(
+ "Config saved", "All channel configs have been saved to flash."
+ )
+
+ self.actionSave_all_configs = QtGui.QAction("Save Config", self)
+ self.actionSave_all_configs.triggered.connect(save)
+ self.addAction(self.actionSave_all_configs)
+
+ def about_thermostat():
+ QtWidgets.QMessageBox.about(
+ self,
+ "About Thermostat",
+ f"""
+