forked from M-Labs/thermostat
Restructure GUI Code, Improve and Fix Bugs
- Bugs fix: 1. Params Tree user input will not get overwritten by incoming report thermostat_data_model. 2. PID Autotune Sampling Period is now set according to Thermostat sampling interval 3. PID Autotune won't get stuck in Fail State 4. Various types disconnection related Bugs 5. Number of Samples stored in the plot cannot be set 6. Limit the max settable output current to be 2000mA - Improvement: 1. Params Tree settings can be changed with external json 2. Use a Tab system to show a single channel of config instead of two 3. Expose PID Autotune lookback params 4. Icon is changed to Artiq logo - Restructure: 1. Restructure the code to follow Model-View-Delegate Design Pattern
This commit is contained in:
parent
8753f4a0fc
commit
9acff86547
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
target/
|
||||
result
|
||||
*.pyc
|
@ -67,6 +67,7 @@ class PIDAutotune:
|
||||
|
||||
def setReady(self):
|
||||
self._state = PIDAutotuneState.STATE_READY
|
||||
self._peak_count = 0
|
||||
|
||||
def setOff(self):
|
||||
self._state = PIDAutotuneState.STATE_OFF
|
||||
|
72
pytec/model/pid_autotuner.py
Normal file
72
pytec/model/pid_autotuner.py
Normal file
@ -0,0 +1,72 @@
|
||||
from PyQt6.QtCore import QObject, pyqtSlot
|
||||
from qasync import asyncSlot
|
||||
from autotune import PIDAutotuneState, PIDAutotune
|
||||
|
||||
|
||||
class PIDAutoTuner(QObject):
|
||||
def __init__(self, parent, client, num_of_channel):
|
||||
super().__init__()
|
||||
|
||||
self._client = client
|
||||
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)]
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_sampling_interval(self, interval):
|
||||
self.sampling_interval = interval
|
||||
|
||||
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()
|
||||
|
||||
async def stop_pid_from_running(self, ch):
|
||||
self.autotuners[ch].setOff()
|
||||
await self._client.set_param("pwm", ch, "i_set", 0)
|
||||
|
||||
@asyncSlot(list)
|
||||
async def tick(self, report):
|
||||
for channel_report in report:
|
||||
# TODO: Skip when PID Autotune or emit error message if NTC is not connected
|
||||
if channel_report["temperature"] is None:
|
||||
continue
|
||||
|
||||
ch = channel_report["channel"]
|
||||
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._client.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()
|
||||
|
||||
await self._client.set_param("pid", ch, "kp", kp)
|
||||
await self._client.set_param("pid", ch, "ki", ki)
|
||||
await self._client.set_param("pid", ch, "kd", kd)
|
||||
await self._client.set_param("pwm", ch, "pid")
|
||||
|
||||
await self._client.set_param(
|
||||
"pid", ch, "target", self.target_temp[ch]
|
||||
)
|
||||
case PIDAutotuneState.STATE_FAILED:
|
||||
self.autotuners[ch].setOff()
|
||||
await self._client.set_param("pwm", ch, "i_set", 0)
|
126
pytec/model/property.py
Normal file
126
pytec/model/property.py
Normal file
@ -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()
|
138
pytec/model/thermostat_data_model.py
Normal file
138
pytec/model/thermostat_data_model.py
Normal file
@ -0,0 +1,138 @@
|
||||
from pytec.aioclient import Client
|
||||
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
|
||||
from qasync import asyncSlot
|
||||
from model.property import Property, PropertyMeta
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
|
||||
class WrappedClient(QObject, Client):
|
||||
connection_error = pyqtSignal()
|
||||
|
||||
async def _read_line(self):
|
||||
try:
|
||||
return await super()._read_line()
|
||||
except (Exception, TimeoutError, asyncio.exceptions.TimeoutError):
|
||||
logging.error("Client connection error, disconnecting", exc_info=True)
|
||||
self.connection_error.emit()
|
||||
|
||||
|
||||
class Thermostat(QObject, metaclass=PropertyMeta):
|
||||
hw_rev = Property(dict)
|
||||
fan = Property(dict)
|
||||
thermistor = Property(list)
|
||||
pid = Property(list)
|
||||
pwm = Property(list)
|
||||
postfilter = Property(list)
|
||||
interval = Property(list)
|
||||
report = Property(list)
|
||||
info_box_trigger = pyqtSignal(str, str)
|
||||
|
||||
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):
|
||||
self.task = asyncio.create_task(self.update_params())
|
||||
while True:
|
||||
if self.task.done():
|
||||
if self.task.exception() is not None:
|
||||
try:
|
||||
raise self.task.exception()
|
||||
except (
|
||||
Exception,
|
||||
TimeoutError,
|
||||
asyncio.exceptions.TimeoutError,
|
||||
):
|
||||
logging.error(
|
||||
"Encountered an error while updating parameter tree.",
|
||||
exc_info=True,
|
||||
)
|
||||
_ = self.task.result()
|
||||
self.task = asyncio.create_task(self.update_params())
|
||||
await asyncio.sleep(self._update_s)
|
||||
|
||||
async def get_hw_rev(self):
|
||||
self.hw_rev = await self._client.hw_rev()
|
||||
return self.hw_rev
|
||||
|
||||
async def update_params(self):
|
||||
self.fan = await self._client.get_fan()
|
||||
self.pwm = await self._client.get_pwm()
|
||||
if self._poll_for_report:
|
||||
self.report = await self._client.report()
|
||||
self.interval = [
|
||||
self.report[i]["interval"] for i in range(len(self.report))
|
||||
]
|
||||
self.pid = await self._client.get_pid()
|
||||
self.thermistor = await self._client.get_steinhart_hart()
|
||||
self.postfilter = await self._client.get_postfilter()
|
||||
|
||||
def connected(self):
|
||||
return self._client.connected
|
||||
|
||||
def connecting(self):
|
||||
return self._client.connecting
|
||||
|
||||
def start_watching(self):
|
||||
self._watch_task = asyncio.create_task(self.run())
|
||||
|
||||
@asyncSlot()
|
||||
async def stop_watching(self):
|
||||
if self._watch_task is not None:
|
||||
await self.set_report_mode(False)
|
||||
self._watch_task.cancel()
|
||||
self._watch_task = None
|
||||
self.task.cancel()
|
||||
self.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()
|
||||
|
||||
async def report_mode(self):
|
||||
async for report in self._client.report_mode():
|
||||
self.report_update.emit(report)
|
||||
self.interval = [
|
||||
self.report[i]["interval"] for i in range(len(self.report))
|
||||
]
|
||||
|
||||
async def disconnect(self):
|
||||
await self._client.end_session()
|
||||
|
||||
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)
|
||||
self.info_box_trigger.emit(
|
||||
"Config loaded", f"Channel {ch} Config has been loaded from flash."
|
||||
)
|
||||
|
||||
@asyncSlot()
|
||||
async def load_cfg(self, ch):
|
||||
await self._client.load_config(ch)
|
||||
self.info_box_trigger.emit(
|
||||
"Config loaded", f"Channel {ch} Config has been loaded from flash."
|
||||
)
|
||||
|
||||
async def dfu(self):
|
||||
await self._client.dfu()
|
||||
|
||||
async def reset(self):
|
||||
await self._client.reset()
|
||||
|
||||
@pyqtSlot(float)
|
||||
def set_update_s(self, update_s):
|
||||
self._update_s = update_s
|
@ -16,3 +16,6 @@ tec_qt = "tec_qt:main"
|
||||
[tool.setuptools]
|
||||
packages.find = {}
|
||||
py-modules = ["aioexample", "autotune", "example", "plot", "tec_qt", "ui_tec_qt", "waitingspinnerwidget"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["*.*"]
|
||||
|
BIN
pytec/resources/artiq.ico
Normal file
BIN
pytec/resources/artiq.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 131 KiB |
1057
pytec/tec_qt.py
1057
pytec/tec_qt.py
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 244 KiB |
@ -1,268 +0,0 @@
|
||||
# Form implementation generated from reading ui file 'tec_qt.ui'
|
||||
#
|
||||
# Created by: PyQt6 UI code generator 6.5.2
|
||||
#
|
||||
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
|
||||
# run again. Do not edit this file unless you know what you are doing.
|
||||
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
|
||||
class Ui_MainWindow(object):
|
||||
def setupUi(self, MainWindow):
|
||||
MainWindow.setObjectName("MainWindow")
|
||||
MainWindow.resize(1280, 720)
|
||||
MainWindow.setMinimumSize(QtCore.QSize(1280, 720))
|
||||
MainWindow.setMaximumSize(QtCore.QSize(3840, 2160))
|
||||
icon = QtGui.QIcon()
|
||||
icon.addPixmap(QtGui.QPixmap("thermostat-icon-640x640.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
|
||||
MainWindow.setWindowIcon(icon)
|
||||
self.main_widget = QtWidgets.QWidget(parent=MainWindow)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(1)
|
||||
sizePolicy.setVerticalStretch(1)
|
||||
sizePolicy.setHeightForWidth(self.main_widget.sizePolicy().hasHeightForWidth())
|
||||
self.main_widget.setSizePolicy(sizePolicy)
|
||||
self.main_widget.setObjectName("main_widget")
|
||||
self.gridLayout_2 = QtWidgets.QGridLayout(self.main_widget)
|
||||
self.gridLayout_2.setContentsMargins(3, 3, 3, 3)
|
||||
self.gridLayout_2.setSpacing(3)
|
||||
self.gridLayout_2.setObjectName("gridLayout_2")
|
||||
self.main_layout = QtWidgets.QVBoxLayout()
|
||||
self.main_layout.setSpacing(0)
|
||||
self.main_layout.setObjectName("main_layout")
|
||||
self.graph_group = QtWidgets.QFrame(parent=self.main_widget)
|
||||
self.graph_group.setEnabled(False)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(1)
|
||||
sizePolicy.setVerticalStretch(1)
|
||||
sizePolicy.setHeightForWidth(self.graph_group.sizePolicy().hasHeightForWidth())
|
||||
self.graph_group.setSizePolicy(sizePolicy)
|
||||
self.graph_group.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
|
||||
self.graph_group.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
|
||||
self.graph_group.setObjectName("graph_group")
|
||||
self.graphs_layout = QtWidgets.QGridLayout(self.graph_group)
|
||||
self.graphs_layout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetDefaultConstraint)
|
||||
self.graphs_layout.setContentsMargins(3, 3, 3, 3)
|
||||
self.graphs_layout.setSpacing(2)
|
||||
self.graphs_layout.setObjectName("graphs_layout")
|
||||
self.ch1_tree = ParameterTree(parent=self.graph_group)
|
||||
self.ch1_tree.setObjectName("ch1_tree")
|
||||
self.graphs_layout.addWidget(self.ch1_tree, 1, 0, 1, 1)
|
||||
self.ch0_tree = ParameterTree(parent=self.graph_group)
|
||||
self.ch0_tree.setObjectName("ch0_tree")
|
||||
self.graphs_layout.addWidget(self.ch0_tree, 0, 0, 1, 1)
|
||||
self.ch1_t_graph = LivePlotWidget(parent=self.graph_group)
|
||||
self.ch1_t_graph.setObjectName("ch1_t_graph")
|
||||
self.graphs_layout.addWidget(self.ch1_t_graph, 1, 1, 1, 1)
|
||||
self.ch0_t_graph = LivePlotWidget(parent=self.graph_group)
|
||||
self.ch0_t_graph.setObjectName("ch0_t_graph")
|
||||
self.graphs_layout.addWidget(self.ch0_t_graph, 0, 1, 1, 1)
|
||||
self.ch0_i_graph = LivePlotWidget(parent=self.graph_group)
|
||||
self.ch0_i_graph.setObjectName("ch0_i_graph")
|
||||
self.graphs_layout.addWidget(self.ch0_i_graph, 0, 2, 1, 1)
|
||||
self.ch1_i_graph = LivePlotWidget(parent=self.graph_group)
|
||||
self.ch1_i_graph.setObjectName("ch1_i_graph")
|
||||
self.graphs_layout.addWidget(self.ch1_i_graph, 1, 2, 1, 1)
|
||||
self.graphs_layout.setColumnMinimumWidth(0, 100)
|
||||
self.graphs_layout.setColumnMinimumWidth(1, 100)
|
||||
self.graphs_layout.setColumnMinimumWidth(2, 100)
|
||||
self.graphs_layout.setRowMinimumHeight(0, 100)
|
||||
self.graphs_layout.setRowMinimumHeight(1, 100)
|
||||
self.graphs_layout.setColumnStretch(0, 1)
|
||||
self.graphs_layout.setColumnStretch(1, 1)
|
||||
self.graphs_layout.setColumnStretch(2, 1)
|
||||
self.graphs_layout.setRowStretch(0, 1)
|
||||
self.graphs_layout.setRowStretch(1, 1)
|
||||
self.main_layout.addWidget(self.graph_group)
|
||||
self.bottom_settings_group = QtWidgets.QFrame(parent=self.main_widget)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.bottom_settings_group.sizePolicy().hasHeightForWidth())
|
||||
self.bottom_settings_group.setSizePolicy(sizePolicy)
|
||||
self.bottom_settings_group.setMinimumSize(QtCore.QSize(0, 40))
|
||||
self.bottom_settings_group.setMaximumSize(QtCore.QSize(16777215, 40))
|
||||
self.bottom_settings_group.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
|
||||
self.bottom_settings_group.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
|
||||
self.bottom_settings_group.setObjectName("bottom_settings_group")
|
||||
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.bottom_settings_group)
|
||||
self.horizontalLayout_2.setContentsMargins(3, 3, 3, 3)
|
||||
self.horizontalLayout_2.setSpacing(3)
|
||||
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
||||
self.settings_layout = QtWidgets.QHBoxLayout()
|
||||
self.settings_layout.setObjectName("settings_layout")
|
||||
self.connect_btn = QtWidgets.QToolButton(parent=self.bottom_settings_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.connect_btn.sizePolicy().hasHeightForWidth())
|
||||
self.connect_btn.setSizePolicy(sizePolicy)
|
||||
self.connect_btn.setMinimumSize(QtCore.QSize(100, 0))
|
||||
self.connect_btn.setMaximumSize(QtCore.QSize(100, 16777215))
|
||||
self.connect_btn.setBaseSize(QtCore.QSize(100, 0))
|
||||
self.connect_btn.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup)
|
||||
self.connect_btn.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonFollowStyle)
|
||||
self.connect_btn.setObjectName("connect_btn")
|
||||
self.settings_layout.addWidget(self.connect_btn)
|
||||
self.status_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.status_lbl.sizePolicy().hasHeightForWidth())
|
||||
self.status_lbl.setSizePolicy(sizePolicy)
|
||||
self.status_lbl.setMinimumSize(QtCore.QSize(240, 0))
|
||||
self.status_lbl.setMaximumSize(QtCore.QSize(120, 16777215))
|
||||
self.status_lbl.setBaseSize(QtCore.QSize(120, 50))
|
||||
self.status_lbl.setObjectName("status_lbl")
|
||||
self.settings_layout.addWidget(self.status_lbl)
|
||||
self.thermostat_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group)
|
||||
self.thermostat_settings.setEnabled(False)
|
||||
self.thermostat_settings.setText("⚙")
|
||||
self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
self.thermostat_settings.setObjectName("thermostat_settings")
|
||||
self.settings_layout.addWidget(self.thermostat_settings)
|
||||
self.plot_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group)
|
||||
self.plot_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
self.plot_settings.setObjectName("plot_settings")
|
||||
self.settings_layout.addWidget(self.plot_settings)
|
||||
self.limits_warning = QtWidgets.QLabel(parent=self.bottom_settings_group)
|
||||
self.limits_warning.setToolTipDuration(1000000000)
|
||||
self.limits_warning.setObjectName("limits_warning")
|
||||
self.settings_layout.addWidget(self.limits_warning)
|
||||
self.background_task_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group)
|
||||
self.background_task_lbl.setObjectName("background_task_lbl")
|
||||
self.settings_layout.addWidget(self.background_task_lbl)
|
||||
self.loading_spinner = QtWaitingSpinner(parent=self.bottom_settings_group)
|
||||
self.loading_spinner.setObjectName("loading_spinner")
|
||||
self.settings_layout.addWidget(self.loading_spinner)
|
||||
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
|
||||
self.settings_layout.addItem(spacerItem)
|
||||
self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group)
|
||||
self.report_group.setEnabled(False)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.report_group.sizePolicy().hasHeightForWidth())
|
||||
self.report_group.setSizePolicy(sizePolicy)
|
||||
self.report_group.setMinimumSize(QtCore.QSize(40, 0))
|
||||
self.report_group.setObjectName("report_group")
|
||||
self.horizontalLayout_4 = QtWidgets.QHBoxLayout(self.report_group)
|
||||
self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0)
|
||||
self.horizontalLayout_4.setSpacing(0)
|
||||
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
|
||||
self.report_layout = QtWidgets.QHBoxLayout()
|
||||
self.report_layout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetDefaultConstraint)
|
||||
self.report_layout.setContentsMargins(0, -1, -1, -1)
|
||||
self.report_layout.setSpacing(6)
|
||||
self.report_layout.setObjectName("report_layout")
|
||||
self.report_lbl = QtWidgets.QLabel(parent=self.report_group)
|
||||
self.report_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||||
self.report_lbl.setObjectName("report_lbl")
|
||||
self.report_layout.addWidget(self.report_lbl)
|
||||
self.report_refresh_spin = QtWidgets.QDoubleSpinBox(parent=self.report_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.report_refresh_spin.sizePolicy().hasHeightForWidth())
|
||||
self.report_refresh_spin.setSizePolicy(sizePolicy)
|
||||
self.report_refresh_spin.setMinimumSize(QtCore.QSize(70, 0))
|
||||
self.report_refresh_spin.setMaximumSize(QtCore.QSize(70, 16777215))
|
||||
self.report_refresh_spin.setBaseSize(QtCore.QSize(70, 0))
|
||||
self.report_refresh_spin.setDecimals(1)
|
||||
self.report_refresh_spin.setMinimum(0.1)
|
||||
self.report_refresh_spin.setSingleStep(0.1)
|
||||
self.report_refresh_spin.setStepType(QtWidgets.QAbstractSpinBox.StepType.AdaptiveDecimalStepType)
|
||||
self.report_refresh_spin.setProperty("value", 1.0)
|
||||
self.report_refresh_spin.setObjectName("report_refresh_spin")
|
||||
self.report_layout.addWidget(self.report_refresh_spin)
|
||||
self.report_box = QtWidgets.QCheckBox(parent=self.report_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.report_box.sizePolicy().hasHeightForWidth())
|
||||
self.report_box.setSizePolicy(sizePolicy)
|
||||
self.report_box.setMaximumSize(QtCore.QSize(80, 16777215))
|
||||
self.report_box.setBaseSize(QtCore.QSize(80, 0))
|
||||
self.report_box.setObjectName("report_box")
|
||||
self.report_layout.addWidget(self.report_box)
|
||||
self.report_apply_btn = QtWidgets.QPushButton(parent=self.report_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.report_apply_btn.sizePolicy().hasHeightForWidth())
|
||||
self.report_apply_btn.setSizePolicy(sizePolicy)
|
||||
self.report_apply_btn.setMinimumSize(QtCore.QSize(80, 0))
|
||||
self.report_apply_btn.setMaximumSize(QtCore.QSize(80, 16777215))
|
||||
self.report_apply_btn.setBaseSize(QtCore.QSize(80, 0))
|
||||
self.report_apply_btn.setObjectName("report_apply_btn")
|
||||
self.report_layout.addWidget(self.report_apply_btn)
|
||||
self.report_layout.setStretch(1, 1)
|
||||
self.report_layout.setStretch(2, 1)
|
||||
self.report_layout.setStretch(3, 1)
|
||||
self.horizontalLayout_4.addLayout(self.report_layout)
|
||||
self.settings_layout.addWidget(self.report_group)
|
||||
self.horizontalLayout_2.addLayout(self.settings_layout)
|
||||
self.main_layout.addWidget(self.bottom_settings_group)
|
||||
self.gridLayout_2.addLayout(self.main_layout, 0, 1, 1, 1)
|
||||
MainWindow.setCentralWidget(self.main_widget)
|
||||
self.actionReset = QtGui.QAction(parent=MainWindow)
|
||||
self.actionReset.setMenuRole(QtGui.QAction.MenuRole.NoRole)
|
||||
self.actionReset.setObjectName("actionReset")
|
||||
self.actionEnter_DFU_Mode = QtGui.QAction(parent=MainWindow)
|
||||
self.actionEnter_DFU_Mode.setMenuRole(QtGui.QAction.MenuRole.NoRole)
|
||||
self.actionEnter_DFU_Mode.setObjectName("actionEnter_DFU_Mode")
|
||||
self.actionNetwork_Settings = QtGui.QAction(parent=MainWindow)
|
||||
self.actionNetwork_Settings.setMenuRole(QtGui.QAction.MenuRole.NoRole)
|
||||
self.actionNetwork_Settings.setObjectName("actionNetwork_Settings")
|
||||
self.actionAbout_Thermostat = QtGui.QAction(parent=MainWindow)
|
||||
self.actionAbout_Thermostat.setMenuRole(QtGui.QAction.MenuRole.NoRole)
|
||||
self.actionAbout_Thermostat.setObjectName("actionAbout_Thermostat")
|
||||
self.actionLoad_all_configs = QtGui.QAction(parent=MainWindow)
|
||||
self.actionLoad_all_configs.setMenuRole(QtGui.QAction.MenuRole.NoRole)
|
||||
self.actionLoad_all_configs.setObjectName("actionLoad_all_configs")
|
||||
self.actionSave_all_configs = QtGui.QAction(parent=MainWindow)
|
||||
self.actionSave_all_configs.setMenuRole(QtGui.QAction.MenuRole.NoRole)
|
||||
self.actionSave_all_configs.setObjectName("actionSave_all_configs")
|
||||
|
||||
self.retranslateUi(MainWindow)
|
||||
QtCore.QMetaObject.connectSlotsByName(MainWindow)
|
||||
|
||||
def retranslateUi(self, MainWindow):
|
||||
_translate = QtCore.QCoreApplication.translate
|
||||
MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel"))
|
||||
self.connect_btn.setText(_translate("MainWindow", "Connect"))
|
||||
self.status_lbl.setText(_translate("MainWindow", "Disconnected"))
|
||||
self.plot_settings.setToolTip(_translate("MainWindow", "Plot Settings"))
|
||||
self.plot_settings.setText(_translate("MainWindow", "📉"))
|
||||
self.background_task_lbl.setText(_translate("MainWindow", "Ready."))
|
||||
self.report_lbl.setText(_translate("MainWindow", "Poll every: "))
|
||||
self.report_refresh_spin.setSuffix(_translate("MainWindow", " s"))
|
||||
self.report_box.setText(_translate("MainWindow", "Report"))
|
||||
self.report_apply_btn.setText(_translate("MainWindow", "Apply"))
|
||||
self.actionReset.setText(_translate("MainWindow", "Reset"))
|
||||
self.actionReset.setToolTip(_translate("MainWindow", "Reset the Thermostat"))
|
||||
self.actionEnter_DFU_Mode.setText(_translate("MainWindow", "Enter DFU Mode"))
|
||||
self.actionEnter_DFU_Mode.setToolTip(_translate("MainWindow", "Reset thermostat and enter USB device firmware update (DFU) mode"))
|
||||
self.actionNetwork_Settings.setText(_translate("MainWindow", "Network Settings"))
|
||||
self.actionNetwork_Settings.setToolTip(_translate("MainWindow", "Configure IPv4 address, netmask length, and optional default gateway"))
|
||||
self.actionAbout_Thermostat.setText(_translate("MainWindow", "About Thermostat"))
|
||||
self.actionAbout_Thermostat.setToolTip(_translate("MainWindow", "Show Thermostat hardware revision, and settings related to i"))
|
||||
self.actionLoad_all_configs.setText(_translate("MainWindow", "Load all channel configs from flash"))
|
||||
self.actionLoad_all_configs.setToolTip(_translate("MainWindow", "Restore configuration for all channels from flash"))
|
||||
self.actionSave_all_configs.setText(_translate("MainWindow", "Save all channel configs to flash"))
|
||||
self.actionSave_all_configs.setToolTip(_translate("MainWindow", "Save configuration for all channels to flash"))
|
||||
from pglive.sources.live_plot_widget import LivePlotWidget
|
||||
from pyqtgraph.parametertree import ParameterTree
|
||||
from waitingspinnerwidget import QtWaitingSpinner
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
MainWindow = QtWidgets.QMainWindow()
|
||||
ui = Ui_MainWindow()
|
||||
ui.setupUi(MainWindow)
|
||||
MainWindow.show()
|
||||
sys.exit(app.exec())
|
56
pytec/view/conn_menu.py
Normal file
56
pytec/view/conn_menu.py
Normal file
@ -0,0 +1,56 @@
|
||||
from PyQt6 import QtWidgets, QtCore
|
||||
|
||||
|
||||
class conn_menu(QtWidgets.QMenu):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
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
|
202
pytec/view/ctrl_panel.py
Normal file
202
pytec/view/ctrl_panel.py
Normal file
@ -0,0 +1,202 @@
|
||||
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
|
||||
import pyqtgraph.parametertree.parameterTypes as pTypes
|
||||
from pyqtgraph.parametertree import (
|
||||
Parameter,
|
||||
registerParameterType,
|
||||
)
|
||||
|
||||
|
||||
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 ctrl_panel(QObject):
|
||||
set_zero_limits_warning_sig = pyqtSignal(list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trees_ui,
|
||||
param_tree,
|
||||
sigTreeStateChanged_handle,
|
||||
sigActivated_handles,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
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(sigTreeStateChanged_handle)
|
||||
|
||||
for handle in sigActivated_handles[i]:
|
||||
self.params[i].child(*handle[0]).sigActivated.connect(handle[1])
|
||||
|
||||
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)
|
||||
|
||||
@pyqtSlot("QVariantList")
|
||||
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("QVariantList")
|
||||
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("QVariantList")
|
||||
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("QVariantList")
|
||||
def update_pwm(self, pwm_data):
|
||||
channels_zeroed_limits = [set() for i in range(self.NUM_CHANNELS)]
|
||||
|
||||
for pwm_params in pwm_data:
|
||||
channel = pwm_params["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child(
|
||||
"Output Config", "Limits", "Max Voltage Difference"
|
||||
).setValue(pwm_params["max_v"]["value"])
|
||||
self.params[channel].child(
|
||||
"Output Config", "Limits", "Max Cooling Current"
|
||||
).setValue(pwm_params["max_i_pos"]["value"] * 1000)
|
||||
self.params[channel].child(
|
||||
"Output Config", "Limits", "Max Heating Current"
|
||||
).setValue(pwm_params["max_i_neg"]["value"] * 1000)
|
||||
|
||||
for limit in "max_i_pos", "max_i_neg", "max_v":
|
||||
if pwm_params[limit]["value"] == 0.0:
|
||||
channels_zeroed_limits[channel].add(limit)
|
||||
self.set_zero_limits_warning_sig.emit(channels_zeroed_limits)
|
||||
|
||||
@pyqtSlot("QVariantList")
|
||||
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"])
|
14
pytec/view/info_box.py
Normal file
14
pytec/view/info_box.py
Normal file
@ -0,0 +1,14 @@
|
||||
from PyQt6 import QtWidgets
|
||||
from PyQt6.QtCore import pyqtSlot
|
||||
|
||||
|
||||
class info_box(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()
|
168
pytec/view/live_plot_view.py
Normal file
168
pytec/view/live_plot_view.py
Normal file
@ -0,0 +1,168 @@
|
||||
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
|
||||
|
||||
pg.setConfigOptions(antialias=True)
|
||||
|
||||
|
||||
class LiveDataPlotter(QObject):
|
||||
def __init__(self, live_plots):
|
||||
super().__init__()
|
||||
|
||||
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]))
|
||||
|
||||
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)
|
||||
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 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"])
|
36
pytec/view/net_settings_input_diag.py
Normal file
36
pytec/view/net_settings_input_diag.py
Normal file
@ -0,0 +1,36 @@
|
||||
from PyQt6 import QtWidgets
|
||||
from PyQt6.QtWidgets import QAbstractButton
|
||||
from PyQt6.QtCore import pyqtSignal, pyqtSlot
|
||||
|
||||
|
||||
class net_settings_input_diag(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:<br>{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)
|
365
pytec/view/param_tree.json
Normal file
365
pytec/view/param_tree.json
Normal file
@ -0,0 +1,365 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"activaters":[
|
||||
null,
|
||||
[
|
||||
"pwm",
|
||||
"ch",
|
||||
"pid"
|
||||
]
|
||||
],
|
||||
"children":[
|
||||
{
|
||||
"name":"Set Current",
|
||||
"type":"float",
|
||||
"value":0,
|
||||
"step":100,
|
||||
"limits":[
|
||||
-2000,
|
||||
2000
|
||||
],
|
||||
"triggerOnShow":true,
|
||||
"decimals":6,
|
||||
"suffix":"mA",
|
||||
"param":[
|
||||
"pwm",
|
||||
"ch",
|
||||
"i_set"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"Set Temperature",
|
||||
"type":"float",
|
||||
"value":25,
|
||||
"step":0.1,
|
||||
"limits":[
|
||||
-273,
|
||||
300
|
||||
],
|
||||
"format":"{value:.4f} °C",
|
||||
"param":[
|
||||
"pid",
|
||||
"ch",
|
||||
"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",
|
||||
"param":[
|
||||
"pwm",
|
||||
"ch",
|
||||
"max_i_pos"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"Max Heating Current",
|
||||
"type":"float",
|
||||
"value":0,
|
||||
"step":100,
|
||||
"decimals":6,
|
||||
"limits":[
|
||||
0,
|
||||
2000
|
||||
],
|
||||
"suffix":"mA",
|
||||
"param":[
|
||||
"pwm",
|
||||
"ch",
|
||||
"max_i_neg"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"Max Voltage Difference",
|
||||
"type":"float",
|
||||
"value":0,
|
||||
"step":0.1,
|
||||
"limits":[
|
||||
0,
|
||||
5
|
||||
],
|
||||
"siPrefix":true,
|
||||
"suffix":"V",
|
||||
"param":[
|
||||
"pwm",
|
||||
"ch",
|
||||
"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",
|
||||
"param":[
|
||||
"s-h",
|
||||
"ch",
|
||||
"t0"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"R₀",
|
||||
"type":"float",
|
||||
"value":10000,
|
||||
"step":1,
|
||||
"siPrefix":true,
|
||||
"suffix":"Ω",
|
||||
"param":[
|
||||
"s-h",
|
||||
"ch",
|
||||
"r0"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"B",
|
||||
"type":"float",
|
||||
"value":3950,
|
||||
"step":1,
|
||||
"suffix":"K",
|
||||
"decimals":4,
|
||||
"param":[
|
||||
"s-h",
|
||||
"ch",
|
||||
"b"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"Postfilter Rate",
|
||||
"type":"list",
|
||||
"value":16.67,
|
||||
"param":[
|
||||
"postfilter",
|
||||
"ch",
|
||||
"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":"",
|
||||
"param":[
|
||||
"pid",
|
||||
"ch",
|
||||
"kp"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"Ki",
|
||||
"type":"float",
|
||||
"step":0.1,
|
||||
"suffix":"Hz",
|
||||
"param":[
|
||||
"pid",
|
||||
"ch",
|
||||
"ki"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"Kd",
|
||||
"type":"float",
|
||||
"step":0.1,
|
||||
"suffix":"s",
|
||||
"param":[
|
||||
"pid",
|
||||
"ch",
|
||||
"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",
|
||||
"param":[
|
||||
"pid",
|
||||
"ch",
|
||||
"output_min"
|
||||
],
|
||||
"lock":false
|
||||
},
|
||||
{
|
||||
"name":"Maximum",
|
||||
"type":"float",
|
||||
"step":100,
|
||||
"limits":[
|
||||
-2000,
|
||||
2000
|
||||
],
|
||||
"decimals":6,
|
||||
"suffix":"mA",
|
||||
"param":[
|
||||
"pid",
|
||||
"ch",
|
||||
"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",
|
||||
"ch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Test Current",
|
||||
"type":"float",
|
||||
"value":0,
|
||||
"decimals":6,
|
||||
"step":100,
|
||||
"limits":[
|
||||
-2000,
|
||||
2000
|
||||
],
|
||||
"suffix":"mA",
|
||||
"pid_autotune":[
|
||||
"test_current",
|
||||
"ch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Temperature Swing",
|
||||
"type":"float",
|
||||
"value":1.5,
|
||||
"step":0.1,
|
||||
"prefix":"±",
|
||||
"format":"{value:.4f} °C",
|
||||
"pid_autotune":[
|
||||
"temp_swing",
|
||||
"ch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Lookback",
|
||||
"type":"float",
|
||||
"value":3.0,
|
||||
"step":0.1,
|
||||
"format":"{value:.4f} s",
|
||||
"pid_autotune":[
|
||||
"lookback",
|
||||
"ch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
21
pytec/view/plot_options_menu.py
Normal file
21
pytec/view/plot_options_menu.py
Normal file
@ -0,0 +1,21 @@
|
||||
from PyQt6 import QtWidgets, QtGui
|
||||
|
||||
|
||||
class plot_options_menu(QtWidgets.QMenu):
|
||||
def __init__(self, max_samples=1000):
|
||||
super().__init__()
|
||||
self.setTitle("Plot Settings")
|
||||
|
||||
clear = QtGui.QAction("Clear graphs", self)
|
||||
self.addAction(clear)
|
||||
self.clear = clear
|
||||
|
||||
self.samples_spinbox = QtWidgets.QSpinBox()
|
||||
self.samples_spinbox.setRange(2, 100000)
|
||||
self.samples_spinbox.setSuffix(" samples")
|
||||
self.samples_spinbox.setValue(max_samples)
|
||||
|
||||
limit_samples = QtWidgets.QWidgetAction(self)
|
||||
limit_samples.setDefaultWidget(self.samples_spinbox)
|
||||
self.addAction(limit_samples)
|
||||
self.limit_samples = limit_samples
|
@ -27,7 +27,7 @@
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset>
|
||||
<normaloff>thermostat-icon-640x640.png</normaloff>thermostat-icon-640x640.png</iconset>
|
||||
<normaloff>../resources/artiq.ico</normaloff>../resources/artiq.ico</iconset>
|
||||
</property>
|
||||
<widget class="QWidget" name="main_widget">
|
||||
<property name="sizePolicy">
|
||||
@ -69,14 +69,14 @@
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::StyledPanel</enum>
|
||||
<enum>QFrame::Shape::StyledPanel</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Raised</enum>
|
||||
<enum>QFrame::Shadow::Raised</enum>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="graphs_layout" rowstretch="1,1" columnstretch="1,1,1" rowminimumheight="100,100" columnminimumwidth="100,100,100">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetDefaultConstraint</enum>
|
||||
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
@ -93,12 +93,6 @@
|
||||
<property name="spacing">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item row="1" column="0">
|
||||
<widget class="ParameterTree" name="ch1_tree" native="true"/>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="ParameterTree" name="ch0_tree" native="true"/>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="LivePlotWidget" name="ch1_t_graph" native="true"/>
|
||||
</item>
|
||||
@ -111,6 +105,65 @@
|
||||
<item row="1" column="2">
|
||||
<widget class="LivePlotWidget" name="ch1_i_graph" native="true"/>
|
||||
</item>
|
||||
<item row="0" column="0" rowspan="2">
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="ch0_tab">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<attribute name="title">
|
||||
<string>Channel 0</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="ParameterTree" name="ch0_tree" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="ch1_tab">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<attribute name="title">
|
||||
<string>Channel 1</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="ParameterTree" name="ch1_tree" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
@ -135,10 +188,10 @@
|
||||
</size>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::StyledPanel</enum>
|
||||
<enum>QFrame::Shape::StyledPanel</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Raised</enum>
|
||||
<enum>QFrame::Shadow::Raised</enum>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<property name="spacing">
|
||||
@ -188,10 +241,10 @@
|
||||
<string>Connect</string>
|
||||
</property>
|
||||
<property name="popupMode">
|
||||
<enum>QToolButton::MenuButtonPopup</enum>
|
||||
<enum>QToolButton::ToolButtonPopupMode::MenuButtonPopup</enum>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonFollowStyle</enum>
|
||||
<enum>Qt::ToolButtonStyle::ToolButtonFollowStyle</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@ -235,7 +288,7 @@
|
||||
<string notr="true">⚙</string>
|
||||
</property>
|
||||
<property name="popupMode">
|
||||
<enum>QToolButton::InstantPopup</enum>
|
||||
<enum>QToolButton::ToolButtonPopupMode::InstantPopup</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@ -248,7 +301,7 @@
|
||||
<string>📉</string>
|
||||
</property>
|
||||
<property name="popupMode">
|
||||
<enum>QToolButton::InstantPopup</enum>
|
||||
<enum>QToolButton::ToolButtonPopupMode::InstantPopup</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@ -272,7 +325,7 @@
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
@ -321,7 +374,7 @@
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetDefaultConstraint</enum>
|
||||
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
@ -332,7 +385,7 @@
|
||||
<string>Poll every: </string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@ -375,7 +428,7 @@
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="stepType">
|
||||
<enum>QAbstractSpinBox::AdaptiveDecimalStepType</enum>
|
||||
<enum>QAbstractSpinBox::StepType::AdaptiveDecimalStepType</enum>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
@ -460,7 +513,7 @@
|
||||
<string>Reset the Thermostat</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionEnter_DFU_Mode">
|
||||
@ -471,7 +524,7 @@
|
||||
<string>Reset thermostat and enter USB device firmware update (DFU) mode</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionNetwork_Settings">
|
||||
@ -482,7 +535,7 @@
|
||||
<string>Configure IPv4 address, netmask length, and optional default gateway</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout_Thermostat">
|
||||
@ -493,7 +546,7 @@
|
||||
<string>Show Thermostat hardware revision, and settings related to i</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionLoad_all_configs">
|
||||
@ -504,7 +557,7 @@
|
||||
<string>Restore configuration for all channels from flash</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave_all_configs">
|
||||
@ -515,7 +568,7 @@
|
||||
<string>Save configuration for all channels to flash</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
@ -535,7 +588,7 @@
|
||||
<customwidget>
|
||||
<class>QtWaitingSpinner</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>waitingspinnerwidget</header>
|
||||
<header>view.waitingspinnerwidget</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
</customwidgets>
|
145
pytec/view/thermostat_ctrl_menu.py
Normal file
145
pytec/view/thermostat_ctrl_menu.py
Normal file
@ -0,0 +1,145 @@
|
||||
from PyQt6 import QtWidgets, QtGui, QtCore
|
||||
from PyQt6.QtCore import pyqtSignal, pyqtSlot
|
||||
|
||||
|
||||
class thermostat_ctrl_menu(QtWidgets.QMenu):
|
||||
fan_set_act = pyqtSignal(int)
|
||||
fan_auto_set_act = pyqtSignal(int)
|
||||
|
||||
connect_act = pyqtSignal()
|
||||
reset_act = pyqtSignal(bool)
|
||||
dfu_act = pyqtSignal(bool)
|
||||
load_cfg_act = pyqtSignal(int)
|
||||
save_cfg_act = pyqtSignal(int)
|
||||
net_cfg_act = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, style):
|
||||
super().__init__()
|
||||
self._style = style
|
||||
self.setTitle("Thermostat settings")
|
||||
|
||||
self.hw_rev_data = dict()
|
||||
|
||||
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_act)
|
||||
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_act)
|
||||
|
||||
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_act)
|
||||
self.addAction(self.actionReset)
|
||||
|
||||
self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self)
|
||||
self.actionEnter_DFU_Mode.triggered.connect(self.dfu_act)
|
||||
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_cfg_act)
|
||||
self.addAction(self.actionnet_settings_input_diag)
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def load(_):
|
||||
self.load_cfg_act.emit(0)
|
||||
self.load_cfg_act.emit(1)
|
||||
loaded = QtWidgets.QMessageBox(self)
|
||||
loaded.setWindowTitle("Config loaded")
|
||||
loaded.setText("All channel configs have been loaded from flash.")
|
||||
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
||||
loaded.show()
|
||||
|
||||
self.actionLoad_all_configs = QtGui.QAction("Load Config", self)
|
||||
self.actionLoad_all_configs.triggered.connect(load)
|
||||
self.addAction(self.actionLoad_all_configs)
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def save(_):
|
||||
self.save_cfg_act.emit(0)
|
||||
self.save_cfg_act.emit(1)
|
||||
saved = QtWidgets.QMessageBox(self)
|
||||
saved.setWindowTitle("Config saved")
|
||||
saved.setText("All channel configs have been saved to flash.")
|
||||
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
||||
saved.show()
|
||||
|
||||
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"""
|
||||
<h1>Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}</h1>
|
||||
|
||||
<br>
|
||||
|
||||
<h2>Settings:</h2>
|
||||
Default fan curve:
|
||||
a = {self.hw_rev_data['settings']['fan_k_a']},
|
||||
b = {self.hw_rev_data['settings']['fan_k_b']},
|
||||
c = {self.hw_rev_data['settings']['fan_k_c']}
|
||||
<br>
|
||||
Fan PWM range:
|
||||
{self.hw_rev_data['settings']['min_fan_pwm']} \u2013 {self.hw_rev_data['settings']['max_fan_pwm']}
|
||||
<br>
|
||||
Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz
|
||||
<br>
|
||||
Fan available: {self.hw_rev_data['settings']['fan_available']}
|
||||
<br>
|
||||
Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']}
|
||||
""",
|
||||
)
|
||||
|
||||
self.actionAbout_Thermostat = QtGui.QAction("About Thermostat", self)
|
||||
self.actionAbout_Thermostat.triggered.connect(about_thermostat)
|
||||
self.addAction(self.actionAbout_Thermostat)
|
||||
|
||||
def set_fan_pwm_warning(self):
|
||||
if self.fan_power_slider.value() != 100:
|
||||
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
|
||||
icon = self._style.standardIcon(pixmapi)
|
||||
self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
|
||||
self.fan_pwm_warning.setToolTip(
|
||||
"Throttling the fan (not recommended on this hardware rev)"
|
||||
)
|
||||
else:
|
||||
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
|
||||
self.fan_pwm_warning.setToolTip("")
|
||||
|
||||
@pyqtSlot("QVariantMap")
|
||||
def hw_rev(self, hw_rev):
|
||||
self.hw_rev_data = hw_rev
|
||||
self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"])
|
41
pytec/view/zero_limits_warning.py
Normal file
41
pytec/view/zero_limits_warning.py
Normal file
@ -0,0 +1,41 @@
|
||||
from PyQt6.QtCore import pyqtSlot, QObject
|
||||
from PyQt6 import QtWidgets, QtGui
|
||||
|
||||
|
||||
class zero_limits_warning_view(QObject):
|
||||
def __init__(self, style, limit_warning):
|
||||
super().__init__()
|
||||
self._lbl = limit_warning
|
||||
self._style = style
|
||||
|
||||
@pyqtSlot("QVariantList")
|
||||
def set_limits_warning(self, channels_zeroed_limits: list):
|
||||
channel_disabled = [False, False]
|
||||
|
||||
report_str = "The following output limit(s) are set to zero:\n"
|
||||
for ch, zeroed_limits in enumerate(channels_zeroed_limits):
|
||||
if {"max_i_pos", "max_i_neg"}.issubset(zeroed_limits):
|
||||
report_str += "Max Cooling Current, Max Heating Current"
|
||||
channel_disabled[ch] = True
|
||||
|
||||
if "max_v" in zeroed_limits:
|
||||
if channel_disabled[ch]:
|
||||
report_str += ", "
|
||||
report_str += "Max Voltage Difference"
|
||||
channel_disabled[ch] = True
|
||||
|
||||
if channel_disabled[ch]:
|
||||
report_str += f" for Channel {ch}\n"
|
||||
|
||||
report_str += (
|
||||
"\nThese limit(s) are restricting the channel(s) from producing current."
|
||||
)
|
||||
|
||||
if True in channel_disabled:
|
||||
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
|
||||
icon = self._style.standardIcon(pixmapi)
|
||||
self._lbl.setPixmap(icon.pixmap(16, 16))
|
||||
self._lbl.setToolTip(report_str)
|
||||
else:
|
||||
self._lbl.setPixmap(QtGui.QPixmap())
|
||||
self._lbl.setToolTip(None)
|
Loading…
Reference in New Issue
Block a user