GUI #101
28
flake.nix
@ -68,13 +68,37 @@
|
||||
propagatedBuildInputs = [ pkgs.python3Packages.pyqt6 ];
|
||||
};
|
||||
|
||||
pyqtgraph = pkgs.python3Packages.buildPythonPackage rec {
|
||||
pname = "pyqtgraph";
|
||||
version = "0.13.3";
|
||||
format = "pyproject";
|
||||
src = pkgs.fetchPypi {
|
||||
inherit pname version;
|
||||
sha256 = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4=";
|
||||
};
|
||||
propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ];
|
||||
};
|
||||
|
||||
pglive = pkgs.python3Packages.buildPythonPackage rec {
|
||||
pname = "pglive";
|
||||
version = "0.7.2";
|
||||
format = "pyproject";
|
||||
src = pkgs.fetchPypi {
|
||||
inherit pname version;
|
||||
sha256 = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A=";
|
||||
};
|
||||
buildInputs = [ pkgs.python3Packages.poetry-core ];
|
||||
propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ];
|
||||
};
|
||||
|
||||
thermostat_gui = pkgs.python3Packages.buildPythonPackage {
|
||||
pname = "thermostat_gui";
|
||||
version = "0.0.0";
|
||||
format = "pyproject";
|
||||
src = "${self}/pytec";
|
||||
|
||||
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
|
||||
propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync ]);
|
||||
propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync pglive ]);
|
||||
|
||||
dontWrapQtApps = true;
|
||||
postFixup = ''
|
||||
@ -100,7 +124,7 @@
|
||||
buildInputs = with pkgs; [
|
||||
rust openocd dfu-util
|
||||
] ++ (with python3Packages; [
|
||||
numpy matplotlib pyqtgraph setuptools pyqt6 qasync
|
||||
numpy matplotlib pyqtgraph setuptools pyqt6 qasync pglive
|
||||
]);
|
||||
};
|
||||
defaultPackage.x86_64-linux = thermostat;
|
||||
|
@ -3,7 +3,7 @@ from pytec.aioclient import Client
|
||||
|
||||
async def main():
|
||||
tec = Client()
|
||||
await tec.connect() #(host="192.168.1.26", port=23)
|
||||
await tec.start_session() #(host="192.168.1.26", port=23)
|
||||
await tec.set_param("s-h", 1, "t0", 20)
|
||||
print(await tec.get_pwm())
|
||||
print(await tec.get_pid())
|
||||
|
@ -222,20 +222,20 @@ class PIDAutotune:
|
||||
# calculate ultimate gain
|
||||
self._Ku = 4.0 * self._outputstep / \
|
||||
(self._induced_amplitude * math.pi)
|
||||
print('Ku: {0}'.format(self._Ku))
|
||||
logging.debug('Ku: {0}'.format(self._Ku))
|
||||
|
||||
# calculate ultimate period in seconds
|
||||
period1 = self._peak_timestamps[3] - self._peak_timestamps[1]
|
||||
period2 = self._peak_timestamps[4] - self._peak_timestamps[2]
|
||||
self._Pu = 0.5 * (period1 + period2) / 1000.0
|
||||
print('Pu: {0}'.format(self._Pu))
|
||||
logging.debug('Pu: {0}'.format(self._Pu))
|
||||
|
||||
for rule in self._tuning_rules:
|
||||
params = self.get_pid_parameters(rule)
|
||||
print('rule: {0}'.format(rule))
|
||||
print('Kp: {0}'.format(params.Kp))
|
||||
print('Ki: {0}'.format(params.Ki))
|
||||
print('Kd: {0}'.format(params.Kd))
|
||||
logging.debug('rule: {0}'.format(rule))
|
||||
logging.debug('Kp: {0}'.format(params.Kp))
|
||||
logging.debug('Ki: {0}'.format(params.Ki))
|
||||
logging.debug('Kd: {0}'.format(params.Kd))
|
||||
|
||||
return True
|
||||
return False
|
||||
|
18
pytec/pyproject.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pytec"
|
||||
version = "0.0"
|
||||
authors = [{name = "M-Labs"}]
|
||||
description = "Control TEC"
|
||||
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
|
||||
license = {text = "GPLv3"}
|
||||
|
||||
[project.gui-scripts]
|
||||
tec_qt = "tec_qt:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages.find = {}
|
||||
py-modules = ["aioexample", "autotune", "example", "plot", "tec_qt", "ui_tec_qt", "waitingspinnerwidget"]
|
@ -5,44 +5,53 @@ import logging
|
||||
class CommandError(Exception):
|
||||
pass
|
||||
|
||||
class StoppedConnecting(Exception):
|
||||
pass
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
self._connecting_task = None
|
||||
self._command_lock = asyncio.Lock()
|
||||
self._report_mode_on = False
|
||||
self.timeout = None
|
||||
|
||||
async def connect(self, host='192.168.1.26', port=23, timeout=None):
|
||||
"""Connect to the TEC with host and port, throws TimeoutError if
|
||||
unable to connect. Returns True if not cancelled with disconnect.
|
||||
async def start_session(self, host='192.168.1.26', port=23, timeout=None):
|
||||
"""Start session to Thermostat at specified host and port.
|
||||
Throws StoppedConnecting if disconnect was called while connecting.
|
||||
Throws asyncio.TimeoutError if timeout was exceeded.
|
||||
|
||||
Example::
|
||||
client = aioclient.Client()
|
||||
connected = await client.connect()
|
||||
if connected:
|
||||
return
|
||||
client = Client()
|
||||
try:
|
||||
await client.start_session()
|
||||
except StoppedConnecting:
|
||||
print("Stopped connecting")
|
||||
"""
|
||||
self._connecting_task = asyncio.create_task(asyncio.open_connection(host, port))
|
||||
self._connecting_task = asyncio.create_task(
|
||||
asyncio.wait_for(asyncio.open_connection(host, port), timeout)
|
||||
)
|
||||
self.timeout = timeout
|
||||
try:
|
||||
self._reader, self._writer = await self._connecting_task
|
||||
except asyncio.CancelledError:
|
||||
return False
|
||||
raise StoppedConnecting
|
||||
finally:
|
||||
self._connecting_task = None
|
||||
|
||||
await self._check_zero_limits()
|
||||
return True
|
||||
|
||||
def is_connecting(self):
|
||||
def connecting(self):
|
||||
"""Returns True if client is connecting"""
|
||||
return self._connecting_task is not None
|
||||
|
||||
def is_connected(self):
|
||||
def connected(self):
|
||||
"""Returns True if client is connected"""
|
||||
return self._writer is not None
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect the client if connected, cancel connection if connecting"""
|
||||
async def end_session(self):
|
||||
"""End session to Thermostat if connected, cancel connection if connecting"""
|
||||
if self._connecting_task is not None:
|
||||
self._connecting_task.cancel()
|
||||
|
||||
@ -64,15 +73,19 @@ class Client:
|
||||
|
||||
async def _read_line(self):
|
||||
# read 1 line
|
||||
chunk = await self._reader.readline()
|
||||
chunk = await asyncio.wait_for(self._reader.readline(), self.timeout) # Only wait for response until timeout
|
||||
return chunk.decode('utf-8', errors='ignore')
|
||||
|
||||
async def _read_write(self, command):
|
||||
self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8'))
|
||||
await self._writer.drain()
|
||||
|
||||
return await self._read_line()
|
||||
|
||||
async def _command(self, *command):
|
||||
async with self._command_lock:
|
||||
self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8'))
|
||||
await self._writer.drain()
|
||||
|
||||
line = await self._read_line()
|
||||
# protect the read-write process from being cancelled midway
|
||||
line = await asyncio.shield(self._read_write(command))
|
||||
|
||||
response = json.loads(line)
|
||||
logging.debug(f"{command}: {response}")
|
||||
@ -147,6 +160,14 @@ class Client:
|
||||
"""
|
||||
return await self._get_conf("postfilter")
|
||||
|
||||
async def get_fan(self):
|
||||
"""Get Thermostat current fan settings"""
|
||||
return await self._command("fan")
|
||||
|
||||
async def report(self):
|
||||
"""Obtain one-time report on measurement values"""
|
||||
return await self._command("report")
|
||||
|
||||
async def report_mode(self):
|
||||
"""Start reporting measurement values
|
||||
|
||||
@ -167,9 +188,11 @@ class Client:
|
||||
'pid_output': 2.067581958092247}
|
||||
"""
|
||||
await self._command("report mode", "on")
|
||||
self._report_mode_on = True
|
||||
|
||||
while True:
|
||||
line = await self._read_line()
|
||||
while self._report_mode_on:
|
||||
async with self._command_lock:
|
||||
line = await self._read_line()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
@ -177,6 +200,11 @@ class Client:
|
||||
except json.decoder.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await self._command("report mode", "off")
|
||||
|
||||
def stop_report_mode(self):
|
||||
self._report_mode_on = False
|
||||
|
||||
async def set_param(self, topic, channel, field="", value=""):
|
||||
"""Set configuration parameters
|
||||
|
||||
@ -195,23 +223,57 @@ class Client:
|
||||
value = str(value)
|
||||
await self._command(topic, str(channel), field, value)
|
||||
|
||||
async def set_fan(self, power="auto"):
|
||||
"""Set fan power"""
|
||||
await self._command("fan", str(power))
|
||||
|
||||
async def set_fcurve(self, a=1.0, b=0.0, c=0.0):
|
||||
"""Set fan curve"""
|
||||
await self._command("fcurve", str(a), str(b), str(c))
|
||||
|
||||
async def power_up(self, channel, target):
|
||||
"""Start closed-loop mode"""
|
||||
await self.set_param("pid", channel, "target", value=target)
|
||||
await self.set_param("pwm", channel, "pid")
|
||||
|
||||
async def save_config(self):
|
||||
async def save_config(self, channel=""):
|
||||
"""Save current configuration to EEPROM"""
|
||||
await self._command("save")
|
||||
await self._command("save", str(channel))
|
||||
|
||||
async def load_config(self):
|
||||
async def load_config(self, channel=""):
|
||||
"""Load current configuration from EEPROM"""
|
||||
await self._command("load")
|
||||
await self._command("load", str(channel))
|
||||
if channel == "":
|
||||
await self._read_line() # Read the extra {}
|
||||
|
||||
async def hw_rev(self):
|
||||
"""Get Thermostat hardware revision"""
|
||||
return await self._command("hwrev")
|
||||
|
||||
async def fan(self):
|
||||
"""Get Thermostat current fan settings"""
|
||||
return await self._command("fan")
|
||||
async def reset(self):
|
||||
"""Reset the Thermostat
|
||||
|
||||
The client is disconnected as the TCP session is terminated.
|
||||
"""
|
||||
async with self._command_lock:
|
||||
self._writer.write("reset\n".encode('utf-8'))
|
||||
await self._writer.drain()
|
||||
|
||||
await self.end_session()
|
||||
|
||||
async def dfu(self):
|
||||
"""Put the Thermostat in DFU update mode
|
||||
|
||||
The client is disconnected as the Thermostat stops responding to
|
||||
TCP commands in DFU update mode. The only way to exit it is by
|
||||
power-cycling.
|
||||
"""
|
||||
async with self._command_lock:
|
||||
self._writer.write("dfu\n".encode('utf-8'))
|
||||
await self._writer.drain()
|
||||
|
||||
await self.end_session()
|
||||
|
||||
async def ipv4(self):
|
||||
"""Get the IPv4 settings of the Thermostat"""
|
||||
return await self._command('ipv4')
|
||||
|
@ -14,5 +14,5 @@ setup(
|
||||
"tec_qt = tec_qt:main",
|
||||
]
|
||||
},
|
||||
py_modules=['tec_qt', 'ui_tec_qt'],
|
||||
py_modules=['tec_qt', 'ui_tec_qt', 'autotune', 'waitingspinnerwidget'],
|
||||
)
|
||||
|
845
pytec/tec_qt.py
@ -1,65 +1,28 @@
|
||||
from PyQt6 import QtWidgets, QtGui
|
||||
from PyQt6 import QtWidgets, QtGui, QtCore
|
||||
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
|
||||
from pyqtgraph import PlotWidget
|
||||
import pyqtgraph.parametertree.parameterTypes as pTypes
|
||||
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType
|
||||
import pyqtgraph as pg
|
||||
pg.setConfigOptions(antialias=True)
|
||||
from pglive.sources.data_connector import DataConnector
|
||||
from pglive.kwargs import Axis
|
||||
from pglive.sources.live_plot import LiveLinePlot
|
||||
from pglive.sources.live_plot_widget import LivePlotWidget
|
||||
from pglive.sources.live_axis import LiveAxis
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
import asyncio
|
||||
from pytec.aioclient import Client
|
||||
from pytec.aioclient import Client, StoppedConnecting
|
||||
import qasync
|
||||
from qasync import asyncSlot, asyncClose
|
||||
from autotune import PIDAutotune, PIDAutotuneState
|
||||
|
||||
# pyuic6 -x tec_qt.ui -o ui_tec_qt.py
|
||||
from ui_tec_qt import Ui_MainWindow
|
||||
|
||||
|
||||
class CommandsParameter(Parameter):
|
||||
def __init__(self, **opts):
|
||||
super().__init__()
|
||||
self.opts["commands"] = opts.get("commands", None)
|
||||
self.opts["payload"] = opts.get("payload", None)
|
||||
|
||||
|
||||
ThermostatParams = [[
|
||||
{'name': 'Constant Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True,
|
||||
'suffix': 'A', 'commands': [f'pwm {ch} i_set {{value}}']},
|
||||
{'name': 'Temperature PID', 'type': 'bool', 'value': False, 'commands': [f'pwm {ch} pid'], 'payload': ch,
|
||||
'children': [
|
||||
{'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True,
|
||||
'suffix': '°C', 'commands': [f'pid {ch} target {{value}}']},
|
||||
]},
|
||||
{'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [
|
||||
{'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True,
|
||||
'suffix': 'A', 'commands': [f'pwm {ch} max_i_pos {{value}}', f'pwm {ch} max_i_neg {{value}}',
|
||||
f'pid {ch} output_min -{{value}}', f'pid {ch} output_max {{value}}']},
|
||||
{'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True,
|
||||
'suffix': 'V', 'commands': [f'pwm {ch} max_v {{value}}']},
|
||||
]},
|
||||
{'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [
|
||||
{'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True,
|
||||
'suffix': 'C', 'commands': [f's-h {ch} t0 {{value}}']},
|
||||
{'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm',
|
||||
'commands': [f's-h {ch} r0 {{value}}']},
|
||||
{'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1, 'commands': [f's-h {ch} b {{value}}']},
|
||||
]},
|
||||
{'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [
|
||||
{'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} kp {{value}}']},
|
||||
{'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} ki {{value}}']},
|
||||
{'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1, 'commands': [f'pid {ch} kd {{value}}']},
|
||||
{'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [
|
||||
{'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'},
|
||||
{'name': 'Test Current', 'type': 'float', 'value': 1, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'},
|
||||
{'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'},
|
||||
{'name': 'Run', 'type': 'action', 'tip': 'Run'},
|
||||
]},
|
||||
]}
|
||||
] for ch in range(2)]
|
||||
|
||||
params = [CommandsParameter.create(name='Thermostat Params 0', type='group', children=ThermostatParams[0]),
|
||||
CommandsParameter.create(name='Thermostat Params 1', type='group', children=ThermostatParams[1])]
|
||||
|
||||
"""Number of channels provided by the Thermostat"""
|
||||
NUM_CHANNELS: int = 2
|
||||
|
||||
def get_argparser():
|
||||
parser = argparse.ArgumentParser(description="ARTIQ master")
|
||||
@ -74,16 +37,66 @@ def get_argparser():
|
||||
return parser
|
||||
|
||||
|
||||
class MutexParameter(pTypes.ListParameter):
|
||||
"""
|
||||
Mutually exclusive parameter where only one of its children is visible at a time, list selectable.
|
||||
|
||||
The ordering of the list items determines which children will be visible.
|
||||
"""
|
||||
def __init__(self, **opts):
|
||||
super().__init__(**opts)
|
||||
|
||||
self.sigValueChanged.connect(self.show_chosen_child)
|
||||
self.sigValueChanged.emit(self, self.opts['value'])
|
||||
|
||||
def _get_param_from_value(self, value):
|
||||
if isinstance(self.opts['limits'], dict):
|
||||
values_list = list(self.opts['limits'].values())
|
||||
else:
|
||||
values_list = self.opts['limits']
|
||||
|
||||
return self.children()[values_list.index(value)]
|
||||
|
||||
@pyqtSlot(object, object)
|
||||
def show_chosen_child(self, value):
|
||||
for param in self.children():
|
||||
param.hide()
|
||||
|
||||
child_to_show = self._get_param_from_value(value.value())
|
||||
child_to_show.show()
|
||||
|
||||
if child_to_show.opts.get('triggerOnShow', None):
|
||||
child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value())
|
||||
|
||||
|
||||
registerParameterType('mutex', MutexParameter)
|
||||
|
||||
|
||||
class WrappedClient(QObject, Client):
|
||||
connection_error = pyqtSignal()
|
||||
|
||||
async def _read_line(self):
|
||||
try:
|
||||
return await super()._read_line()
|
||||
except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11
|
||||
logging.error("Client connection error, disconnecting", exc_info=True)
|
||||
self.connection_error.emit()
|
||||
|
||||
|
||||
class ClientWatcher(QObject):
|
||||
fan_update = pyqtSignal(dict)
|
||||
pwm_update = pyqtSignal(list)
|
||||
report_update = pyqtSignal(list)
|
||||
pid_update = pyqtSignal(list)
|
||||
thermistor_update = pyqtSignal(list)
|
||||
postfilter_update = pyqtSignal(list)
|
||||
|
||||
def __init__(self, parent, client, update_s):
|
||||
self.update_s = update_s
|
||||
self.client = client
|
||||
self.watch_task = None
|
||||
self._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):
|
||||
@ -91,93 +104,517 @@ class ClientWatcher(QObject):
|
||||
while True:
|
||||
time = loop.time()
|
||||
await self.update_params()
|
||||
await asyncio.sleep(self.update_s - (loop.time() - time))
|
||||
await asyncio.sleep(self._update_s - (loop.time() - time))
|
||||
|
||||
|
||||
async def update_params(self):
|
||||
self.fan_update.emit(await self.client.fan())
|
||||
self.pwm_update.emit(await self.client.get_pwm())
|
||||
self.report_update.emit(await self.client._command("report"))
|
||||
self.pid_update.emit(await self.client.get_pid())
|
||||
self.fan_update.emit(await self._client.get_fan())
|
||||
self.pwm_update.emit(await self._client.get_pwm())
|
||||
if self._poll_for_report:
|
||||
self.report_update.emit(await self._client.report())
|
||||
self.pid_update.emit(await self._client.get_pid())
|
||||
self.thermistor_update.emit(await self._client.get_steinhart_hart())
|
||||
self.postfilter_update.emit(await self._client.get_postfilter())
|
||||
|
||||
def start_watching(self):
|
||||
self.watch_task = asyncio.create_task(self.run())
|
||||
|
||||
def is_watching(self):
|
||||
return self.watch_task is not None
|
||||
self._watch_task = asyncio.create_task(self.run())
|
||||
|
||||
@pyqtSlot()
|
||||
def stop_watching(self):
|
||||
if self.watch_task is not None:
|
||||
self.watch_task.cancel()
|
||||
self.watch_task = None
|
||||
if self._watch_task is not None:
|
||||
self._watch_task.cancel()
|
||||
self._watch_task = None
|
||||
|
||||
async def set_report_mode(self, enabled: bool):
|
||||
self._poll_for_report = not enabled
|
||||
if enabled:
|
||||
self._report_mode_task = asyncio.create_task(self.report_mode())
|
||||
else:
|
||||
self._client.stop_report_mode()
|
||||
if self._report_mode_task is not None:
|
||||
await self._report_mode_task
|
||||
self._report_mode_task = None
|
||||
|
||||
async def report_mode(self):
|
||||
async for report in self._client.report_mode():
|
||||
self.report_update.emit(report)
|
||||
|
||||
@pyqtSlot(float)
|
||||
def set_update_s(self, update_s):
|
||||
self.update_s = update_s
|
||||
self._update_s = update_s
|
||||
|
||||
|
||||
class ChannelGraphs:
|
||||
"""Manager of a channel's two graphs and their elements."""
|
||||
|
||||
"""The maximum number of sample points to store."""
|
||||
DEFAULT_MAX_SAMPLES = 1000
|
||||
|
||||
def __init__(self, t_widget, i_widget):
|
||||
self._t_widget = t_widget
|
||||
self._i_widget = i_widget
|
||||
|
||||
self._t_plot = LiveLinePlot()
|
||||
self._i_plot = LiveLinePlot(name="Measured")
|
||||
self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen('r'))
|
||||
|
||||
self._t_line = self._t_widget.getPlotItem().addLine(label='{value} °C')
|
||||
self._t_line.setVisible(False)
|
||||
self._t_setpoint_plot = LiveLinePlot() # Hack for keeping setpoint line in plot range
|
||||
|
||||
for graph in t_widget, i_widget:
|
||||
time_axis = LiveAxis('bottom', text="Time since Thermostat reset", **{Axis.TICK_FORMAT: Axis.DURATION})
|
||||
time_axis.showLabel()
|
||||
graph.setAxisItems({'bottom': time_axis})
|
||||
|
||||
graph.add_crosshair(pg.mkPen(color='red', width=1), {'color': 'green'})
|
||||
|
||||
# Enable linking of axes in the graph widget's context menu
|
||||
graph.register(graph.getPlotItem().titleLabel.text) # Slight hack getting the title
|
||||
|
||||
temperature_axis = LiveAxis('left', text="Temperature", units="°C")
|
||||
temperature_axis.showLabel()
|
||||
t_widget.setAxisItems({'left': temperature_axis})
|
||||
|
||||
current_axis = LiveAxis('left', text="Current", units="A")
|
||||
current_axis.showLabel()
|
||||
i_widget.setAxisItems({'left': current_axis})
|
||||
i_widget.addLegend(brush=(50, 50, 200, 150))
|
||||
|
||||
t_widget.addItem(self._t_plot)
|
||||
t_widget.addItem(self._t_setpoint_plot)
|
||||
i_widget.addItem(self._i_plot)
|
||||
i_widget.addItem(self._iset_plot)
|
||||
|
||||
self.t_connector = DataConnector(self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES)
|
||||
self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1)
|
||||
self.i_connector = DataConnector(self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES)
|
||||
self.iset_connector = DataConnector(self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES)
|
||||
|
||||
self.max_samples = self.DEFAULT_MAX_SAMPLES
|
||||
|
||||
def plot_append(self, report):
|
||||
temperature = report['temperature']
|
||||
current = report['tec_i']
|
||||
iset = report['i_set']
|
||||
time = report['time']
|
||||
|
||||
if temperature is not None:
|
||||
self.t_connector.cb_append_data_point(temperature, time)
|
||||
if self._t_line.isVisible():
|
||||
self.t_setpoint_connector.cb_append_data_point(self._t_line.value(), time)
|
||||
else:
|
||||
self.t_setpoint_connector.cb_append_data_point(temperature, time)
|
||||
if current is not None:
|
||||
self.i_connector.cb_append_data_point(current, time)
|
||||
self.iset_connector.cb_append_data_point(iset, time)
|
||||
|
||||
def clear(self):
|
||||
for connector in self.t_connector, self.i_connector, self.iset_connector:
|
||||
connector.clear()
|
||||
|
||||
def set_t_line(self, temp=None, visible=None):
|
||||
if visible is not None:
|
||||
self._t_line.setVisible(visible)
|
||||
if temp is not None:
|
||||
self._t_line.setValue(temp)
|
||||
|
||||
# PyQtGraph normally does not update this text when the line
|
||||
# is not visible, so make sure that the temperature label
|
||||
# gets updated always, and doesn't stay at an old value.
|
||||
self._t_line.label.setText(f"{temp} °C")
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
|
||||
"""The maximum number of sample points to store."""
|
||||
DEFAULT_MAX_SAMPLES = 1000
|
||||
|
||||
"""Thermostat parameters that are particular to a channel"""
|
||||
THERMOSTAT_PARAMETERS = [[
|
||||
esavkin
commented
Also I think it is possible to make this define not only the UI, but also getters/setters from the data state object. Also I think it is possible to make this define not only the UI, but also getters/setters from the data state object.
esavkin
commented
It is not an exactly straight-forward way, but you can inherit the ParameterTree or similar to add the additional fields. The getters/setters may be just functions, that accept state object and the incoming value, alternatively you can add lambda's, but that may need wrapping this list into class or function. It is not an exactly straight-forward way, but you can inherit the ParameterTree or similar to add the additional fields. The getters/setters may be just functions, that accept state object and the incoming value, alternatively you can add lambda's, but that may need wrapping this list into class or function.
|
||||
{'name': 'Temperature', 'type': 'float', 'format': '{value:.4f} °C', 'readonly': True},
|
||||
{'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True},
|
||||
{'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [
|
||||
{'name': 'Control Method', 'type': 'mutex', 'limits': ['Constant Current', 'Temperature PID'],
|
||||
'activaters': [None, ('pwm', ch, 'pid')], 'children': [
|
||||
{'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True,
|
||||
'decimals': 6, 'suffix': 'mA', 'param': ('pwm', ch, 'i_set')},
|
||||
{'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300),
|
||||
'format': '{value:.4f} °C', 'param': ('pid', ch, 'target')},
|
||||
]},
|
||||
{'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [
|
||||
{'name': 'Max Cooling Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000),
|
||||
'suffix': 'mA', 'param': ('pwm', ch, 'max_i_pos')},
|
||||
{'name': 'Max Heating Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000),
|
||||
'suffix': 'mA', 'param': ('pwm', ch, 'max_i_neg')},
|
||||
{'name': 'Max Voltage Difference', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True,
|
||||
'suffix': 'V', 'param': ('pwm', ch, 'max_v')},
|
||||
]}
|
||||
]},
|
||||
{'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [
|
||||
{'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100),
|
||||
'format': '{value:.4f} °C', 'param': ('s-h', ch, 't0')},
|
||||
{'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω',
|
||||
'param': ('s-h', ch, 'r0')},
|
||||
{'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': ('s-h', ch, 'b')},
|
||||
{'name': 'Postfilter Rate', 'type': 'list', 'value': 16.67, 'param': ('postfilter', ch, 'rate'),
|
||||
'limits': {'Off': None, '16.67 Hz': 16.67, '20 Hz': 20.0, '21.25 Hz': 21.25, '27 Hz': 27.0}},
|
||||
]},
|
||||
{'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [
|
||||
{'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': ('pid', ch, 'kp')},
|
||||
{'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': ('pid', ch, 'ki')},
|
||||
{'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': ('pid', ch, 'kd')},
|
||||
{'name': "PID Output Clamping", 'expanded': True, 'type': 'group', 'children': [
|
||||
{'name': 'Minimum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_min')},
|
||||
{'name': 'Maximum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_max')},
|
||||
]},
|
||||
{'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [
|
||||
{'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'format': '{value:.4f} °C'},
|
||||
{'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'},
|
||||
{'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'prefix': '±', 'format': '{value:.4f} °C'},
|
||||
{'name': 'Run', 'type': 'action', 'tip': 'Run'},
|
||||
]},
|
||||
]},
|
||||
{'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'},
|
||||
{'name': 'Load from flash', 'type': 'action', 'tip': 'Load config from flash'}
|
||||
] for ch in range(NUM_CHANNELS)]
|
||||
|
||||
def __init__(self, args):
|
||||
super().__init__()
|
||||
|
||||
self.setupUi(self)
|
||||
|
||||
self._set_up_context_menu()
|
||||
self.ch0_t_graph.setTitle("Channel 0 Temperature")
|
||||
self.ch0_i_graph.setTitle("Channel 0 Current")
|
||||
self.ch1_t_graph.setTitle("Channel 1 Temperature")
|
||||
self.ch1_i_graph.setTitle("Channel 1 Current")
|
||||
|
||||
self.fan_power_slider.valueChanged.connect(self.fan_set)
|
||||
self.fan_auto_box.stateChanged.connect(self.fan_auto_set)
|
||||
self.max_samples = self.DEFAULT_MAX_SAMPLES
|
||||
|
||||
self._set_param_tree()
|
||||
self._set_up_connection_menu()
|
||||
self._set_up_thermostat_menu()
|
||||
self._set_up_plot_menu()
|
||||
|
||||
self.fan_pwm_recommended = False
|
||||
|
||||
self.tec_client = Client()
|
||||
self.client_watcher = ClientWatcher(self, self.tec_client, self.report_refresh_spin.value())
|
||||
self.client = WrappedClient(self)
|
||||
self.client.connection_error.connect(self.bail)
|
||||
self.client_watcher = ClientWatcher(self, self.client, self.report_refresh_spin.value())
|
||||
self.client_watcher.fan_update.connect(self.fan_update)
|
||||
self.client_watcher.report_update.connect(self.update_report)
|
||||
self.client_watcher.pid_update.connect(self.update_pid)
|
||||
self.client_watcher.pwm_update.connect(self.update_pwm)
|
||||
self.client_watcher.thermistor_update.connect(self.update_thermistor)
|
||||
self.client_watcher.postfilter_update.connect(self.update_postfilter)
|
||||
self.report_apply_btn.clicked.connect(
|
||||
lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value())
|
||||
)
|
||||
|
||||
self.params = [
|
||||
Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch])
|
||||
for ch in range(NUM_CHANNELS)
|
||||
]
|
||||
self._set_param_tree()
|
||||
|
||||
self.channel_graphs = [
|
||||
ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph'))
|
||||
for ch in range(NUM_CHANNELS)
|
||||
]
|
||||
|
||||
self.autotuners = [
|
||||
PIDAutotune(25)
|
||||
for _ in range(NUM_CHANNELS)
|
||||
]
|
||||
|
||||
self.loading_spinner.hide()
|
||||
|
||||
self.hw_rev_data = None
|
||||
|
||||
if args.connect:
|
||||
if args.IP:
|
||||
self.ip_set_line.setText(args.IP)
|
||||
self.host_set_line.setText(args.IP)
|
||||
if args.PORT:
|
||||
self.port_set_spin.setValue(int(args.PORT))
|
||||
self.connect_btn.click()
|
||||
|
||||
def _set_up_context_menu(self):
|
||||
self.menu = QtWidgets.QMenu()
|
||||
self.menu.setTitle('Thermostat settings')
|
||||
def _set_up_connection_menu(self):
|
||||
self.connection_menu = QtWidgets.QMenu()
|
||||
self.connection_menu.setTitle('Connection Settings')
|
||||
|
||||
port = QtWidgets.QWidgetAction(self.menu)
|
||||
self.host_set_line = QtWidgets.QLineEdit()
|
||||
self.host_set_line.setMinimumSize(QtCore.QSize(160, 0))
|
||||
self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215))
|
||||
self.host_set_line.setMaxLength(15)
|
||||
self.host_set_line.setClearButtonEnabled(True)
|
||||
|
||||
def connect_on_enter_press():
|
||||
self.connect_btn.click()
|
||||
self.connection_menu.hide()
|
||||
self.host_set_line.returnPressed.connect(connect_on_enter_press)
|
||||
|
||||
self.host_set_line.setText("192.168.1.26")
|
||||
self.host_set_line.setPlaceholderText("IP for the Thermostat")
|
||||
|
||||
host = QtWidgets.QWidgetAction(self.connection_menu)
|
||||
host.setDefaultWidget(self.host_set_line)
|
||||
self.connection_menu.addAction(host)
|
||||
self.connection_menu.host = host
|
||||
|
||||
self.port_set_spin = QtWidgets.QSpinBox()
|
||||
self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0))
|
||||
self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215))
|
||||
self.port_set_spin.setMaximum(65535)
|
||||
self.port_set_spin.setValue(23)
|
||||
|
||||
def connect_only_if_enter_pressed():
|
||||
if not self.port_set_spin.hasFocus(): # Don't connect if the spinbox only lost focus
|
||||
return;
|
||||
connect_on_enter_press()
|
||||
self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
|
||||
|
||||
port = QtWidgets.QWidgetAction(self.connection_menu)
|
||||
port.setDefaultWidget(self.port_set_spin)
|
||||
self.menu.addAction(port)
|
||||
self.menu.port = port
|
||||
self.connection_menu.addAction(port)
|
||||
self.connection_menu.port = port
|
||||
|
||||
fan = QtWidgets.QWidgetAction(self.menu)
|
||||
self.exit_button = QtWidgets.QPushButton()
|
||||
self.exit_button.setText("Exit GUI")
|
||||
self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
|
||||
|
||||
exit_action = QtWidgets.QWidgetAction(self.exit_button)
|
||||
exit_action.setDefaultWidget(self.exit_button)
|
||||
self.connection_menu.addAction(exit_action)
|
||||
self.connection_menu.exit_action = exit_action
|
||||
|
||||
self.connect_btn.setMenu(self.connection_menu)
|
||||
|
||||
def _set_up_thermostat_menu(self):
|
||||
self.thermostat_menu = QtWidgets.QMenu()
|
||||
self.thermostat_menu.setTitle('Thermostat settings')
|
||||
|
||||
self.fan_group = QtWidgets.QWidget()
|
||||
esavkin
commented
Is there a good reason why these menu widgets are in the code instead of UI files? You may also separate UI into different reusable widgets in most cases Is there a good reason why these menu widgets are in the code instead of UI files? You may also separate UI into different reusable widgets in most cases
|
||||
self.fan_group.setEnabled(False)
|
||||
self.fan_group.setMinimumSize(QtCore.QSize(40, 0))
|
||||
self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group)
|
||||
self.fan_layout.setSpacing(9)
|
||||
self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group)
|
||||
self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0))
|
||||
self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215))
|
||||
self.fan_lbl.setBaseSize(QtCore.QSize(40, 0))
|
||||
self.fan_layout.addWidget(self.fan_lbl)
|
||||
self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group)
|
||||
self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0))
|
||||
self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215))
|
||||
self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0))
|
||||
self.fan_power_slider.setRange(1, 100)
|
||||
self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal)
|
||||
self.fan_layout.addWidget(self.fan_power_slider)
|
||||
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
|
||||
self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0))
|
||||
self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215))
|
||||
self.fan_layout.addWidget(self.fan_auto_box)
|
||||
self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
|
||||
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
|
||||
self.fan_layout.addWidget(self.fan_pwm_warning)
|
||||
|
||||
self.fan_power_slider.valueChanged.connect(self.fan_set)
|
||||
self.fan_auto_box.stateChanged.connect(self.fan_auto_set)
|
||||
|
||||
self.fan_lbl.setToolTip("Adjust the fan")
|
||||
self.fan_lbl.setText("Fan:")
|
||||
self.fan_auto_box.setText("Auto")
|
||||
|
||||
fan = QtWidgets.QWidgetAction(self.thermostat_menu)
|
||||
fan.setDefaultWidget(self.fan_group)
|
||||
self.menu.addAction(fan)
|
||||
self.menu.fan = fan
|
||||
self.thermostat_menu.addAction(fan)
|
||||
self.thermostat_menu.fan = fan
|
||||
|
||||
self.thermostat_settings.setMenu(self.menu)
|
||||
@asyncSlot(bool)
|
||||
async def reset_thermostat(_):
|
||||
await self._on_connection_changed(False)
|
||||
await self.client.reset()
|
||||
await asyncio.sleep(0.1) # Wait for the reset to start
|
||||
|
||||
self.connect_btn.click() # Reconnect
|
||||
|
||||
self.actionReset.triggered.connect(reset_thermostat)
|
||||
self.thermostat_menu.addAction(self.actionReset)
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def dfu_mode(_):
|
||||
await self._on_connection_changed(False)
|
||||
await self.client.dfu()
|
||||
|
||||
# TODO: add a firmware flashing GUI?
|
||||
|
||||
self.actionEnter_DFU_Mode.triggered.connect(dfu_mode)
|
||||
self.thermostat_menu.addAction(self.actionEnter_DFU_Mode)
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def network_settings(_):
|
||||
ask_network = QtWidgets.QInputDialog(self)
|
||||
ask_network.setWindowTitle("Network Settings")
|
||||
ask_network.setLabelText("Set the Thermostat's IPv4 address, netmask and gateway (optional)")
|
||||
linuswck
commented
For the network related configuration, there should be a form for the user to fill in instead of entering the configuration as a line of text. For the network related configuration, there should be a form for the user to fill in instead of entering the configuration as a line of text.
esavkin
commented
Not really, making smooth transition between dots may be troublesome. Some regex validation though would be nice. Not really, making smooth transition between dots may be troublesome. Some regex validation though would be nice.
linuswck
commented
QT supports tab orderings. Will that be enough? QT supports tab orderings. Will that be enough?
atse
commented
Will Thermostat connections always be configured through an IPv4 address though? Don't we need to support more than that, like for instance hostnames resolved through DNS? A use-case of this is using SSH local forwarding to remotely control a Thermostat with the GUI; this worked just fine once I adjusted the host and port. Will Thermostat connections always be configured through an IPv4 address though? Don't we need to support more than that, like for instance hostnames resolved through DNS?
A use-case of this is using SSH local forwarding to remotely control a Thermostat with the GUI; this worked just fine once I adjusted the host and port.
|
||||
ask_network.setTextValue((await self.client.ipv4())['addr'])
|
||||
|
||||
@pyqtSlot(str)
|
||||
def set_ipv4(ipv4_settings):
|
||||
sure = QtWidgets.QMessageBox(self)
|
||||
sure.setWindowTitle("Set network?")
|
||||
sure.setText(f"Setting this as network and disconnecting:<br>{ipv4_settings}")
|
||||
|
||||
@asyncSlot(object)
|
||||
async def really_set(button):
|
||||
await self.client.set_param("ipv4", ipv4_settings)
|
||||
await self.client.disconnect()
|
||||
|
||||
await self._on_connection_changed(False)
|
||||
|
||||
sure.buttonClicked.connect(really_set)
|
||||
sure.show()
|
||||
ask_network.textValueSelected.connect(set_ipv4)
|
||||
ask_network.show()
|
||||
|
||||
self.actionNetwork_Settings.triggered.connect(network_settings)
|
||||
self.thermostat_menu.addAction(self.actionNetwork_Settings)
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def load(_):
|
||||
await self.client.load_config()
|
||||
loaded = QtWidgets.QMessageBox(self)
|
||||
loaded.setWindowTitle("Config loaded")
|
||||
loaded.setText(f"All channel configs have been loaded from flash.")
|
||||
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
||||
loaded.show()
|
||||
|
||||
self.actionLoad_all_configs.triggered.connect(load)
|
||||
self.thermostat_menu.addAction(self.actionLoad_all_configs)
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def save(_):
|
||||
await self.client.save_config()
|
||||
saved = QtWidgets.QMessageBox(self)
|
||||
saved.setWindowTitle("Config saved")
|
||||
saved.setText(f"All channel configs have been saved to flash.")
|
||||
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
||||
saved.show()
|
||||
|
||||
self.actionSave_all_configs.triggered.connect(save)
|
||||
self.thermostat_menu.addAction(self.actionSave_all_configs)
|
||||
|
||||
def about_thermostat():
|
||||
QtWidgets.QMessageBox.about(
|
||||
self,
|
||||
"About Thermostat",
|
||||
f"""
|
||||
<h1>Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}</h1>
|
||||
esavkin
commented
This doesn't feel to be hardcoded. This doesn't feel to be hardcoded.
|
||||
|
||||
<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.triggered.connect(about_thermostat)
|
||||
self.thermostat_menu.addAction(self.actionAbout_Thermostat)
|
||||
|
||||
self.thermostat_settings.setMenu(self.thermostat_menu)
|
||||
|
||||
def _set_up_plot_menu(self):
|
||||
self.plot_menu = QtWidgets.QMenu()
|
||||
self.plot_menu.setTitle("Plot Settings")
|
||||
|
||||
clear = QtGui.QAction("Clear graphs", self.plot_menu)
|
||||
clear.triggered.connect(self.clear_graphs)
|
||||
self.plot_menu.addAction(clear)
|
||||
self.plot_menu.clear = clear
|
||||
|
||||
self.samples_spinbox = QtWidgets.QSpinBox()
|
||||
self.samples_spinbox.setRange(2, 100000)
|
||||
self.samples_spinbox.setSuffix(' samples')
|
||||
self.samples_spinbox.setValue(self.max_samples)
|
||||
self.samples_spinbox.valueChanged.connect(self.set_max_samples)
|
||||
|
||||
limit_samples = QtWidgets.QWidgetAction(self.plot_menu)
|
||||
limit_samples.setDefaultWidget(self.samples_spinbox)
|
||||
self.plot_menu.addAction(limit_samples)
|
||||
self.plot_menu.limit_samples = limit_samples
|
||||
|
||||
self.plot_settings.setMenu(self.plot_menu)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def set_limits_warning(self, channels_zeroed_limits: list):
|
||||
channel_disabled = [False, False]
|
||||
|
||||
report_str = "The following output limit(s) are set to zero:\n"
|
||||
for ch, zeroed_limits in enumerate(channels_zeroed_limits):
|
||||
if {'max_i_pos', 'max_i_neg'}.issubset(zeroed_limits):
|
||||
report_str += "Max Cooling Current, Max Heating Current"
|
||||
channel_disabled[ch] = True
|
||||
|
||||
if 'max_v' in zeroed_limits:
|
||||
if channel_disabled[ch]:
|
||||
report_str += ", "
|
||||
report_str += "Max Voltage Difference"
|
||||
channel_disabled[ch] = True
|
||||
|
||||
if channel_disabled[ch]:
|
||||
report_str += f" for Channel {ch}\n"
|
||||
|
||||
report_str += "\nThese limit(s) are restricting the channel(s) from producing current."
|
||||
|
||||
if True in channel_disabled:
|
||||
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
|
||||
icon = self.style().standardIcon(pixmapi)
|
||||
self.limits_warning.setPixmap(icon.pixmap(16, 16))
|
||||
self.limits_warning.setToolTip(report_str)
|
||||
else:
|
||||
self.limits_warning.setPixmap(QtGui.QPixmap())
|
||||
self.limits_warning.setToolTip(None)
|
||||
|
||||
@pyqtSlot(int)
|
||||
def set_max_samples(self, samples: int):
|
||||
for channel_graph in self.channel_graphs:
|
||||
channel_graph.t_connector.max_points = samples
|
||||
linuswck
commented
The "max_points" feature does not work as I tested on GUI. I tried to re-declare the data queue into the DataConnector class and it works as expected. Not sure if there is better way of doing it.
The "max_points" feature does not work as I tested on GUI.
Upon inspection on the source code of the library. You cannot change it dynamically with the built-in fns
I tried to re-declare the data queue into the DataConnector class and it works as expected. Not sure if there is better way of doing it.
``` Python
connector.max_points = self.max_samples
connector.x = deque(maxlen=int(connector.max_points))
connector.y = deque(maxlen=int(connector.max_points))
```
|
||||
channel_graph.i_connector.max_points = samples
|
||||
channel_graph.iset_connector.max_points = samples
|
||||
|
||||
def clear_graphs(self):
|
||||
for channel_graph in self.channel_graphs:
|
||||
channel_graph.clear()
|
||||
|
||||
async def _on_connection_changed(self, result):
|
||||
self.graph_group.setEnabled(result)
|
||||
self.fan_group.setEnabled(result)
|
||||
self.report_group.setEnabled(result)
|
||||
self.thermostat_settings.setEnabled(result)
|
||||
|
||||
self.ip_set_line.setEnabled(not result)
|
||||
self.host_set_line.setEnabled(not result)
|
||||
self.port_set_spin.setEnabled(not result)
|
||||
self.connect_btn.setText("Disconnect" if result else "Connect")
|
||||
if result:
|
||||
self.hw_rev_data = await self.client.hw_rev()
|
||||
self._status(self.hw_rev_data)
|
||||
self.client_watcher.start_watching()
|
||||
self._status(await self.tec_client.hw_rev())
|
||||
self.fan_update(await self.tec_client.fan())
|
||||
# await self.client.set_param("fan", 1)
|
||||
else:
|
||||
self.status_lbl.setText("Disconnected")
|
||||
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
|
||||
self.fan_pwm_warning.setToolTip("")
|
||||
self.clear_graphs()
|
||||
self.report_box.setChecked(False)
|
||||
await self.client_watcher.set_report_mode(False)
|
||||
self.client_watcher.stop_watching()
|
||||
self.status_lbl.setText("Disconnected")
|
||||
|
||||
def _set_fan_pwm_warning(self):
|
||||
if self.fan_power_slider.value() != 100:
|
||||
@ -193,7 +630,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
logging.debug(hw_rev_d)
|
||||
self.status_lbl.setText(f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}")
|
||||
self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"])
|
||||
self.fan_pwm_recommended = hw_rev_d["settings"]["fan_pwm_recommended"]
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def fan_update(self, fan_settings: dict):
|
||||
@ -204,90 +640,243 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.fan_power_slider.setValue(fan_settings["fan_pwm"] or 100) # 0 = PWM off = full strength
|
||||
with QSignalBlocker(self.fan_auto_box):
|
||||
self.fan_auto_box.setChecked(fan_settings["auto_mode"])
|
||||
if not self.fan_pwm_recommended:
|
||||
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
|
||||
self._set_fan_pwm_warning()
|
||||
|
||||
@asyncSlot(int)
|
||||
async def fan_set(self, value):
|
||||
if not self.tec_client.is_connected():
|
||||
if not self.client.connected():
|
||||
return
|
||||
if self.fan_auto_box.isChecked():
|
||||
with QSignalBlocker(self.fan_auto_box):
|
||||
self.fan_auto_box.setChecked(False)
|
||||
await self.tec_client.set_param("fan", value)
|
||||
if not self.fan_pwm_recommended:
|
||||
await self.client.set_fan(value)
|
||||
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
|
||||
self._set_fan_pwm_warning()
|
||||
|
||||
@asyncSlot(int)
|
||||
async def fan_auto_set(self, enabled):
|
||||
if not self.tec_client.is_connected():
|
||||
if not self.client.connected():
|
||||
return
|
||||
if enabled:
|
||||
await self.tec_client.set_param("fan", "auto")
|
||||
self.fan_update(await self.tec_client.fan())
|
||||
await self.client.set_fan("auto")
|
||||
self.fan_update(await self.client.get_fan())
|
||||
else:
|
||||
await self.tec_client.set_param("fan", self.fan_power_slider.value())
|
||||
await self.client.set_fan(self.fan_power_slider.value())
|
||||
|
||||
@asyncSlot(int)
|
||||
async def on_report_box_stateChanged(self, enabled):
|
||||
await self.client_watcher.set_report_mode(enabled)
|
||||
|
||||
@asyncClose
|
||||
async def closeEvent(self, event):
|
||||
self.client_watcher.stop_watching()
|
||||
await self.tec_client.disconnect()
|
||||
await self.bail()
|
||||
|
||||
@asyncSlot()
|
||||
async def on_connect_btn_clicked(self):
|
||||
ip, port = self.ip_set_line.text(), self.port_set_spin.value()
|
||||
host, port = self.host_set_line.text(), self.port_set_spin.value()
|
||||
try:
|
||||
if not (self.tec_client.is_connecting() or self.tec_client.is_connected()):
|
||||
if not (self.client.connecting() or self.client.connected()):
|
||||
self.status_lbl.setText("Connecting...")
|
||||
self.connect_btn.setText("Stop")
|
||||
self.ip_set_line.setEnabled(False)
|
||||
self.host_set_line.setEnabled(False)
|
||||
self.port_set_spin.setEnabled(False)
|
||||
|
||||
connected = await self.tec_client.connect(host=ip, port=port, timeout=30)
|
||||
if not connected:
|
||||
try:
|
||||
await self.client.start_session(host=host, port=port, timeout=30)
|
||||
except StoppedConnecting:
|
||||
return
|
||||
await self._on_connection_changed(True)
|
||||
else:
|
||||
await self._on_connection_changed(False)
|
||||
await self.tec_client.disconnect()
|
||||
await self.bail()
|
||||
|
||||
except (OSError, TimeoutError) as e:
|
||||
logging.error(f"Failed communicating to {ip}:{port}: {e}")
|
||||
await self._on_connection_changed(False)
|
||||
await self.tec_client.disconnect()
|
||||
except (OSError, TimeoutError, asyncio.TimeoutError) as e: # TODO: Remove asyncio.TimeoutError in Python 3.11
|
||||
logging.error(f"Failed communicating to {host}:{port}: {e}")
|
||||
await self.bail()
|
||||
|
||||
@asyncSlot()
|
||||
async def bail(self):
|
||||
await self._on_connection_changed(False)
|
||||
await self.client.end_session()
|
||||
|
||||
@asyncSlot(object, object)
|
||||
async def send_command(self, param, changes):
|
||||
for param, change, data in changes:
|
||||
if param.name() == 'Temperature PID' and not data:
|
||||
ch = param.opts["payload"]
|
||||
await self.tec_client.set_param('pwm', ch, 'i_set', params[ch].child('Constant Current').value())
|
||||
elif param.opts.get("commands", None) is not None:
|
||||
await asyncio.gather(*[self.tec_client._command(x.format(value=data)) for x in param.opts["commands"]])
|
||||
"""Translates parameter tree changes into thermostat set_param calls"""
|
||||
|
||||
for inner_param, change, data in changes:
|
||||
if change == 'value':
|
||||
if inner_param.opts.get("param", None) is not None:
|
||||
if 'Current' in inner_param.name():
|
||||
data /= 1000 # Given in mA
|
||||
|
||||
thermostat_param = inner_param.opts["param"]
|
||||
if inner_param.name() == 'Postfilter Rate' and data == None:
|
||||
set_param_args = (*thermostat_param[:2], 'off')
|
||||
else:
|
||||
set_param_args = (*thermostat_param, data)
|
||||
await self.client.set_param(*set_param_args)
|
||||
if inner_param.opts.get('activaters', None) is not None:
|
||||
activater = inner_param.opts['activaters'][inner_param.opts['limits'].index(data)]
|
||||
if activater is not None:
|
||||
await self.client.set_param(*activater)
|
||||
|
||||
|
||||
def _set_param_tree(self):
|
||||
self.ch0_tree.setParameters(params[0], showTop=False)
|
||||
self.ch1_tree.setParameters(params[1], showTop=False)
|
||||
params[0].sigTreeStateChanged.connect(self.send_command)
|
||||
params[1].sigTreeStateChanged.connect(self.send_command)
|
||||
for i, tree in enumerate((self.ch0_tree, self.ch1_tree)):
|
||||
tree.setHeaderHidden(True)
|
||||
tree.setParameters(self.params[i], showTop=False)
|
||||
self.params[i].sigTreeStateChanged.connect(self.send_command)
|
||||
|
||||
@asyncSlot()
|
||||
async def save(_, ch=i):
|
||||
await self.client.save_config(ch)
|
||||
saved = QtWidgets.QMessageBox(self)
|
||||
saved.setWindowTitle("Config saved")
|
||||
saved.setText(f"Channel {ch} Config has been saved to flash.")
|
||||
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
||||
saved.show()
|
||||
|
||||
self.params[i].child('Save to flash').sigActivated.connect(save)
|
||||
|
||||
@asyncSlot()
|
||||
async def load(_, ch=i):
|
||||
await self.client.load_config(ch)
|
||||
loaded = QtWidgets.QMessageBox(self)
|
||||
loaded.setWindowTitle("Config loaded")
|
||||
loaded.setText(f"Channel {ch} Config has been loaded from flash.")
|
||||
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
||||
loaded.show()
|
||||
|
||||
self.params[i].child('Load from flash').sigActivated.connect(load)
|
||||
|
||||
@asyncSlot()
|
||||
async def autotune(param, ch=i):
|
||||
match self.autotuners[ch].state():
|
||||
case PIDAutotuneState.STATE_OFF:
|
||||
self.autotuners[ch].setParam(
|
||||
param.parent().child('Target Temperature').value(),
|
||||
param.parent().child('Test Current').value() / 1000,
|
||||
param.parent().child('Temperature Swing').value(),
|
||||
self.report_refresh_spin.value(),
|
||||
3)
|
||||
self.autotuners[ch].setReady()
|
||||
param.setOpts(title="Stop")
|
||||
self.client_watcher.report_update.connect(self.autotune_tick)
|
||||
self.loading_spinner.show()
|
||||
self.loading_spinner.start()
|
||||
if self.autotuners[1 - ch].state() == PIDAutotuneState.STATE_OFF:
|
||||
self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=ch))
|
||||
else:
|
||||
self.background_task_lbl.setText("Autotuning channel 0 and 1...")
|
||||
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
|
||||
self.autotuners[ch].setOff()
|
||||
param.setOpts(title="Run")
|
||||
await self.client.set_param('pwm', ch, 'i_set', 0)
|
||||
self.client_watcher.report_update.disconnect(self.autotune_tick)
|
||||
if self.autotuners[1 - ch].state() == PIDAutotuneState.STATE_OFF:
|
||||
self.background_task_lbl.setText("Ready.")
|
||||
self.loading_spinner.stop()
|
||||
self.loading_spinner.hide()
|
||||
else:
|
||||
self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch))
|
||||
|
||||
self.params[i].child('PID Config', 'PID Auto Tune', 'Run').sigActivated.connect(autotune)
|
||||
|
||||
@asyncSlot(list)
|
||||
async def autotune_tick(self, report):
|
||||
for channel_report in report:
|
||||
channel = channel_report['channel']
|
||||
match self.autotuners[channel].state():
|
||||
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
|
||||
self.autotuners[channel].run(channel_report['temperature'], channel_report['time'])
|
||||
await self.client.set_param('pwm', channel, 'i_set', self.autotuners[channel].output())
|
||||
case PIDAutotuneState.STATE_SUCCEEDED:
|
||||
kp, ki, kd = self.autotuners[channel].get_tec_pid()
|
||||
self.autotuners[channel].setOff()
|
||||
self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run")
|
||||
await self.client.set_param('pid', channel, 'kp', kp)
|
||||
await self.client.set_param('pid', channel, 'ki', ki)
|
||||
await self.client.set_param('pid', channel, 'kd', kd)
|
||||
await self.client.set_param('pwm', channel, 'pid')
|
||||
await self.client.set_param('pid', channel, 'target', self.params[channel].child("PID Config", "PID Auto Tune", "Target Temperature").value())
|
||||
self.client_watcher.report_update.disconnect(self.autotune_tick)
|
||||
if self.autotuners[1 - channel].state() == PIDAutotuneState.STATE_OFF:
|
||||
self.background_task_lbl.setText("Ready.")
|
||||
self.loading_spinner.stop()
|
||||
self.loading_spinner.hide()
|
||||
else:
|
||||
self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch))
|
||||
case PIDAutotuneState.STATE_FAILED:
|
||||
self.autotuners[channel].setOff()
|
||||
self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run")
|
||||
await self.client.set_param('pwm', channel, 'i_set', 0)
|
||||
self.client_watcher.report_update.disconnect(self.autotune_tick)
|
||||
if self.autotuners[1 - channel].state() == PIDAutotuneState.STATE_OFF:
|
||||
self.background_task_lbl.setText("Ready.")
|
||||
self.loading_spinner.stop()
|
||||
self.loading_spinner.hide()
|
||||
else:
|
||||
self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch))
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_pid(self, pid_settings):
|
||||
for settings in pid_settings:
|
||||
channel = settings["channel"]
|
||||
with QSignalBlocker(params[channel]):
|
||||
params[channel].child("PID Config", "kP").setValue(settings["parameters"]["kp"])
|
||||
params[channel].child("PID Config", "kI").setValue(settings["parameters"]["ki"])
|
||||
params[channel].child("PID Config", "kD").setValue(settings["parameters"]["kd"])
|
||||
if params[channel].child("Temperature PID").value():
|
||||
params[channel].child("Temperature PID", "Set Temperature").setValue(settings["target"])
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("PID Config", "Kp").setValue(settings["parameters"]["kp"])
|
||||
self.params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"])
|
||||
self.params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"])
|
||||
self.params[channel].child("PID Config", "PID Output Clamping", "Minimum").setValue(settings["parameters"]["output_min"] * 1000)
|
||||
self.params[channel].child("PID Config", "PID Output Clamping", "Maximum").setValue(settings["parameters"]["output_max"] * 1000)
|
||||
self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"])
|
||||
self.channel_graphs[channel].set_t_line(temp=round(settings["target"], 6))
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_report(self, report_data):
|
||||
for settings in report_data:
|
||||
channel = settings["channel"]
|
||||
with QSignalBlocker(params[channel]):
|
||||
params[channel].child("Temperature PID").setValue(settings["pid_engaged"])
|
||||
self.channel_graphs[channel].plot_append(settings)
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("Output Config", "Control Method").setValue("Temperature PID" if settings["pid_engaged"] else "Constant Current")
|
||||
self.channel_graphs[channel].set_t_line(visible=settings['pid_engaged'])
|
||||
self.params[channel].child("Output Config", "Control Method", "Set Current").setValue(settings["i_set"] * 1000)
|
||||
if settings['temperature'] is not None:
|
||||
self.params[channel].child("Temperature").setValue(settings['temperature'])
|
||||
if settings['tec_i'] is not None:
|
||||
self.params[channel].child("Current through TEC").setValue(settings['tec_i'] * 1000)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_thermistor(self, sh_data):
|
||||
for sh_param in sh_data:
|
||||
channel = sh_param["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("Thermistor Config", "T₀").setValue(sh_param["params"]["t0"] - 273.15)
|
||||
self.params[channel].child("Thermistor Config", "R₀").setValue(sh_param["params"]["r0"])
|
||||
self.params[channel].child("Thermistor Config", "B").setValue(sh_param["params"]["b"])
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_pwm(self, pwm_data):
|
||||
channels_zeroed_limits = [set() for i in range(NUM_CHANNELS)]
|
||||
|
||||
for pwm_params in pwm_data:
|
||||
channel = pwm_params["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("Output Config", "Limits", "Max Voltage Difference").setValue(pwm_params["max_v"]["value"])
|
||||
self.params[channel].child("Output Config", "Limits", "Max Cooling Current").setValue(pwm_params["max_i_pos"]["value"] * 1000)
|
||||
self.params[channel].child("Output Config", "Limits", "Max Heating Current").setValue(pwm_params["max_i_neg"]["value"] * 1000)
|
||||
|
||||
for limit in "max_i_pos", "max_i_neg", "max_v":
|
||||
if pwm_params[limit]["value"] == 0.0:
|
||||
channels_zeroed_limits[channel].add(limit)
|
||||
|
||||
self.set_limits_warning(channels_zeroed_limits)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_postfilter(self, postfilter_data):
|
||||
for postfilter_params in postfilter_data:
|
||||
channel = postfilter_params["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("Thermistor Config", "Postfilter Rate").setValue(postfilter_params["rate"])
|
||||
|
||||
|
||||
async def coro_main():
|
||||
|
376
pytec/tec_qt.ui
@ -25,6 +25,10 @@
|
||||
<property name="windowTitle">
|
||||
<string>Thermostat Control Panel</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset>
|
||||
<normaloff>thermostat-icon-640x640.png</normaloff>thermostat-icon-640x640.png</iconset>
|
||||
esavkin
commented
Well, I see two main problems with it (even if it is working):
In this case, I would go with one of our standard ARTIQ icons, or create one from scratch with fewest details possible, targeting sizes 16x16, 32x32, 48x48, 64x64 and 256x256 pixels (it should just look okay being scaled to all sizes, no need to create different versions). For this, SVGs are most suitable, and they can be created in Inkscape (their UX is not good though) or any other software of your choice, and exported as Optimized SVG, but also preserving the original is desired in case of future needs (possibly can be uploaded to private repo). Well, I see two main problems with it (even if it is working):
1. Such large PNG is not really suitable to be an icon, the standard is to use SVG and generate appropriate-sized PNGs if needed (in most cases SVG is fine).
2. Photos are not really suitable to be an icons in modern UIs, as they usually have a lot of details, that will be just removed. Also it makes it feel the software to be 20+ years old. This particular photo will be looking like some black brick once it is displayed as a window icon of size 48px.
In this case, I would go with one of our standard ARTIQ icons, or create one from scratch with fewest details possible, targeting sizes 16x16, 32x32, 48x48, 64x64 and 256x256 pixels (it should just look okay being scaled to all sizes, no need to create different versions). For this, SVGs are most suitable, and they can be created in Inkscape (their UX is not good though) or any other software of your choice, and exported as Optimized SVG, but also preserving the original is desired in case of future needs (possibly can be uploaded to private repo).
esavkin
commented
Actually better it be in separate PR, because the icon creation can take a while, and it is not really necessary to be merged with this PR Actually better it be in separate PR, because the icon creation can take a while, and it is not really necessary to be merged with this PR
|
||||
</property>
|
||||
<widget class="QWidget" name="main_widget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
@ -96,32 +100,16 @@
|
||||
<widget class="ParameterTree" name="ch0_tree" native="true"/>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="PlotWidget" name="ch1_t_graph" native="true">
|
||||
<property name="title">
|
||||
<string>Channel 1 Temperature</string>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="LivePlotWidget" name="ch1_t_graph" native="true"/>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="PlotWidget" name="ch0_t_graph" native="true">
|
||||
<property name="title">
|
||||
<string>Channel 0 Temperature</string>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="LivePlotWidget" name="ch0_t_graph" native="true"/>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="PlotWidget" name="ch0_i_graph" native="true">
|
||||
<property name="title">
|
||||
<string>Channel 0 Current</string>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="LivePlotWidget" name="ch0_i_graph" native="true"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="PlotWidget" name="ch1_i_graph" native="true">
|
||||
<property name="title">
|
||||
<string>Channel 1 Current</string>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="LivePlotWidget" name="ch1_i_graph" native="true"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
@ -171,69 +159,7 @@
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="settings_layout">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="ip_set_line">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>160</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>160</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>192.168.1.26</string>
|
||||
</property>
|
||||
<property name="maxLength">
|
||||
<number>15</number>
|
||||
</property>
|
||||
<property name="placeholderText">
|
||||
<string>IP:port for the Thermostat</string>
|
||||
</property>
|
||||
<property name="clearButtonEnabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="port_set_spin">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>70</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>70</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>65535</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>23</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="connect_btn">
|
||||
<widget class="QToolButton" name="connect_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
@ -261,6 +187,12 @@
|
||||
<property name="text">
|
||||
<string>Connect</string>
|
||||
</property>
|
||||
<property name="popupMode">
|
||||
<enum>QToolButton::MenuButtonPopup</enum>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonFollowStyle</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
@ -296,6 +228,9 @@
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="thermostat_settings">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string notr="true">⚙</string>
|
||||
</property>
|
||||
@ -305,176 +240,47 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line_0">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Minimum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
<widget class="QToolButton" name="plot_settings">
|
||||
<property name="toolTip">
|
||||
<string>Plot Settings</string>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
<property name="text">
|
||||
<string>📉</string>
|
||||
</property>
|
||||
<property name="popupMode">
|
||||
<enum>QToolButton::InstantPopup</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="fan_group" native="true">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
<widget class="QLabel" name="limits_warning">
|
||||
<property name="toolTipDuration">
|
||||
<number>1000000000</number>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="background_task_lbl">
|
||||
<property name="text">
|
||||
<string>Ready.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QtWaitingSpinner" name="loading_spinner" native="true"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>0</height>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="gan_layout">
|
||||
<property name="spacing">
|
||||
<number>9</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="fan_pwm_warning">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>16</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="fan_lbl">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="baseSize">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Adjust the fan</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Fan:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSlider" name="fan_power_slider">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="baseSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>100</number>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="fan_auto_box">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>70</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>70</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Auto</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line_1">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Minimum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="report_group" native="true">
|
||||
@ -646,20 +452,92 @@
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<action name="actionReset">
|
||||
<property name="text">
|
||||
<string>Reset</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Reset the Thermostat</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionEnter_DFU_Mode">
|
||||
<property name="text">
|
||||
<string>Enter DFU Mode</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Reset thermostat and enter USB device firmware update (DFU) mode</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionNetwork_Settings">
|
||||
<property name="text">
|
||||
<string>Network Settings</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Configure IPv4 address, netmask length, and optional default gateway</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout_Thermostat">
|
||||
<property name="text">
|
||||
<string>About Thermostat</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Show Thermostat hardware revision, and settings related to i</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionLoad_all_configs">
|
||||
<property name="text">
|
||||
<string>Load all channel configs from flash</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Restore configuration for all channels from flash</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave_all_configs">
|
||||
<property name="text">
|
||||
<string>Save all channel configs to flash</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Save configuration for all channels to flash</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>PlotWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>pyqtgraph</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ParameterTree</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>pyqtgraph.parametertree</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>LivePlotWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>pglive.sources.live_plot_widget</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>QtWaitingSpinner</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>waitingspinnerwidget</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
|
BIN
pytec/thermostat-icon-640x640.png
Normal file
After Width: | Height: | Size: 244 KiB |
@ -1,6 +1,6 @@
|
||||
# Form implementation generated from reading ui file 'tec_qt.ui'
|
||||
#
|
||||
# Created by: PyQt6 UI code generator 6.4.2
|
||||
# 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.
|
||||
@ -15,6 +15,9 @@ class Ui_MainWindow(object):
|
||||
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)
|
||||
@ -50,16 +53,16 @@ class Ui_MainWindow(object):
|
||||
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 = PlotWidget(parent=self.graph_group)
|
||||
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 = PlotWidget(parent=self.graph_group)
|
||||
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 = PlotWidget(parent=self.graph_group)
|
||||
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 = PlotWidget(parent=self.graph_group)
|
||||
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)
|
||||
@ -90,31 +93,7 @@ class Ui_MainWindow(object):
|
||||
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
||||
self.settings_layout = QtWidgets.QHBoxLayout()
|
||||
self.settings_layout.setObjectName("settings_layout")
|
||||
self.ip_set_line = QtWidgets.QLineEdit(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.ip_set_line.sizePolicy().hasHeightForWidth())
|
||||
self.ip_set_line.setSizePolicy(sizePolicy)
|
||||
self.ip_set_line.setMinimumSize(QtCore.QSize(160, 0))
|
||||
self.ip_set_line.setMaximumSize(QtCore.QSize(160, 16777215))
|
||||
self.ip_set_line.setMaxLength(15)
|
||||
self.ip_set_line.setClearButtonEnabled(True)
|
||||
self.ip_set_line.setObjectName("ip_set_line")
|
||||
self.settings_layout.addWidget(self.ip_set_line)
|
||||
self.port_set_spin = QtWidgets.QSpinBox(parent=self.bottom_settings_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.port_set_spin.sizePolicy().hasHeightForWidth())
|
||||
self.port_set_spin.setSizePolicy(sizePolicy)
|
||||
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.setProperty("value", 23)
|
||||
self.port_set_spin.setObjectName("port_set_spin")
|
||||
self.settings_layout.addWidget(self.port_set_spin)
|
||||
self.connect_btn = QtWidgets.QPushButton(parent=self.bottom_settings_group)
|
||||
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)
|
||||
@ -123,6 +102,8 @@ class Ui_MainWindow(object):
|
||||
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)
|
||||
@ -137,83 +118,27 @@ class Ui_MainWindow(object):
|
||||
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.line_0 = QtWidgets.QFrame(parent=self.bottom_settings_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.line_0.sizePolicy().hasHeightForWidth())
|
||||
self.line_0.setSizePolicy(sizePolicy)
|
||||
self.line_0.setFrameShape(QtWidgets.QFrame.Shape.VLine)
|
||||
self.line_0.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
|
||||
self.line_0.setObjectName("line_0")
|
||||
self.settings_layout.addWidget(self.line_0)
|
||||
self.fan_group = QtWidgets.QWidget(parent=self.bottom_settings_group)
|
||||
self.fan_group.setEnabled(False)
|
||||
self.fan_group.setMinimumSize(QtCore.QSize(40, 0))
|
||||
self.fan_group.setObjectName("fan_group")
|
||||
self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.fan_group)
|
||||
self.horizontalLayout_6.setContentsMargins(0, 0, 0, 0)
|
||||
self.horizontalLayout_6.setSpacing(0)
|
||||
self.horizontalLayout_6.setObjectName("horizontalLayout_6")
|
||||
self.gan_layout = QtWidgets.QHBoxLayout()
|
||||
self.gan_layout.setSpacing(9)
|
||||
self.gan_layout.setObjectName("gan_layout")
|
||||
self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
|
||||
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
|
||||
self.fan_pwm_warning.setText("")
|
||||
self.fan_pwm_warning.setObjectName("fan_pwm_warning")
|
||||
self.gan_layout.addWidget(self.fan_pwm_warning)
|
||||
self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.fan_lbl.sizePolicy().hasHeightForWidth())
|
||||
self.fan_lbl.setSizePolicy(sizePolicy)
|
||||
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_lbl.setObjectName("fan_lbl")
|
||||
self.gan_layout.addWidget(self.fan_lbl)
|
||||
self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.fan_power_slider.sizePolicy().hasHeightForWidth())
|
||||
self.fan_power_slider.setSizePolicy(sizePolicy)
|
||||
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.setMinimum(1)
|
||||
self.fan_power_slider.setMaximum(100)
|
||||
self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal)
|
||||
self.fan_power_slider.setObjectName("fan_power_slider")
|
||||
self.gan_layout.addWidget(self.fan_power_slider)
|
||||
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.fan_auto_box.sizePolicy().hasHeightForWidth())
|
||||
self.fan_auto_box.setSizePolicy(sizePolicy)
|
||||
self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0))
|
||||
self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215))
|
||||
self.fan_auto_box.setObjectName("fan_auto_box")
|
||||
self.gan_layout.addWidget(self.fan_auto_box)
|
||||
self.horizontalLayout_6.addLayout(self.gan_layout)
|
||||
self.settings_layout.addWidget(self.fan_group)
|
||||
self.line_1 = QtWidgets.QFrame(parent=self.bottom_settings_group)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.line_1.sizePolicy().hasHeightForWidth())
|
||||
self.line_1.setSizePolicy(sizePolicy)
|
||||
self.line_1.setFrameShape(QtWidgets.QFrame.Shape.VLine)
|
||||
self.line_1.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
|
||||
self.line_1.setObjectName("line_1")
|
||||
self.settings_layout.addWidget(self.line_1)
|
||||
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)
|
||||
@ -282,6 +207,24 @@ class Ui_MainWindow(object):
|
||||
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)
|
||||
@ -289,23 +232,30 @@ class Ui_MainWindow(object):
|
||||
def retranslateUi(self, MainWindow):
|
||||
_translate = QtCore.QCoreApplication.translate
|
||||
MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel"))
|
||||
self.ch1_t_graph.setTitle(_translate("MainWindow", "Channel 1 Temperature"))
|
||||
self.ch0_t_graph.setTitle(_translate("MainWindow", "Channel 0 Temperature"))
|
||||
self.ch0_i_graph.setTitle(_translate("MainWindow", "Channel 0 Current"))
|
||||
self.ch1_i_graph.setTitle(_translate("MainWindow", "Channel 1 Current"))
|
||||
self.ip_set_line.setText(_translate("MainWindow", "192.168.1.26"))
|
||||
self.ip_set_line.setPlaceholderText(_translate("MainWindow", "IP:port for the Thermostat"))
|
||||
self.connect_btn.setText(_translate("MainWindow", "Connect"))
|
||||
self.status_lbl.setText(_translate("MainWindow", "Disconnected"))
|
||||
self.fan_lbl.setToolTip(_translate("MainWindow", "Adjust the fan"))
|
||||
self.fan_lbl.setText(_translate("MainWindow", "Fan:"))
|
||||
self.fan_auto_box.setText(_translate("MainWindow", "Auto"))
|
||||
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"))
|
||||
from pyqtgraph import PlotWidget
|
||||
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__":
|
||||
|
194
pytec/waitingspinnerwidget.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2012-2014 Alexander Turkin
|
||||
Copyright (c) 2014 William Hallatt
|
||||
Copyright (c) 2015 Jacob Dawid
|
||||
Copyright (c) 2016 Luca Weiss
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from PyQt6.QtCore import *
|
||||
from PyQt6.QtGui import *
|
||||
from PyQt6.QtWidgets import *
|
||||
|
||||
|
||||
class QtWaitingSpinner(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# WAS IN initialize()
|
||||
self._color = QColor(Qt.GlobalColor.black)
|
||||
self._roundness = 100.0
|
||||
self._minimumTrailOpacity = 3.14159265358979323846
|
||||
self._trailFadePercentage = 80.0
|
||||
self._revolutionsPerSecond = 1.57079632679489661923
|
||||
self._numberOfLines = 20
|
||||
self._lineLength = 5
|
||||
self._lineWidth = 2
|
||||
self._innerRadius = 5
|
||||
self._currentCounter = 0
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self.rotate)
|
||||
self.updateSize()
|
||||
self.updateTimer()
|
||||
# END initialize()
|
||||
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
def paintEvent(self, QPaintEvent):
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
|
||||
if self._currentCounter >= self._numberOfLines:
|
||||
self._currentCounter = 0
|
||||
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
for i in range(0, self._numberOfLines):
|
||||
painter.save()
|
||||
painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength)
|
||||
rotateAngle = float(360 * i) / float(self._numberOfLines)
|
||||
painter.rotate(rotateAngle)
|
||||
painter.translate(self._innerRadius, 0)
|
||||
distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines)
|
||||
color = self.currentLineColor(distance, self._numberOfLines, self._trailFadePercentage,
|
||||
self._minimumTrailOpacity, self._color)
|
||||
painter.setBrush(color)
|
||||
painter.drawRoundedRect(QRect(0, int(-self._lineWidth / 2), self._lineLength, self._lineWidth), self._roundness,
|
||||
self._roundness, Qt.SizeMode.RelativeSize)
|
||||
painter.restore()
|
||||
|
||||
def start(self):
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
self._currentCounter = 0
|
||||
|
||||
def stop(self):
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
self._currentCounter = 0
|
||||
|
||||
def setNumberOfLines(self, lines):
|
||||
self._numberOfLines = lines
|
||||
self._currentCounter = 0
|
||||
self.updateTimer()
|
||||
|
||||
def setLineLength(self, length):
|
||||
self._lineLength = length
|
||||
self.updateSize()
|
||||
|
||||
def setLineWidth(self, width):
|
||||
self._lineWidth = width
|
||||
self.updateSize()
|
||||
|
||||
def setInnerRadius(self, radius):
|
||||
self._innerRadius = radius
|
||||
self.updateSize()
|
||||
|
||||
def color(self):
|
||||
return self._color
|
||||
|
||||
def roundness(self):
|
||||
return self._roundness
|
||||
|
||||
def minimumTrailOpacity(self):
|
||||
return self._minimumTrailOpacity
|
||||
|
||||
def trailFadePercentage(self):
|
||||
return self._trailFadePercentage
|
||||
|
||||
def revolutionsPersSecond(self):
|
||||
return self._revolutionsPerSecond
|
||||
|
||||
def numberOfLines(self):
|
||||
return self._numberOfLines
|
||||
|
||||
def lineLength(self):
|
||||
return self._lineLength
|
||||
|
||||
def lineWidth(self):
|
||||
return self._lineWidth
|
||||
|
||||
def innerRadius(self):
|
||||
return self._innerRadius
|
||||
|
||||
def setRoundness(self, roundness):
|
||||
self._roundness = max(0.0, min(100.0, roundness))
|
||||
|
||||
def setColor(self, color=Qt.GlobalColor.black):
|
||||
self._color = QColor(color)
|
||||
|
||||
def setRevolutionsPerSecond(self, revolutionsPerSecond):
|
||||
self._revolutionsPerSecond = revolutionsPerSecond
|
||||
self.updateTimer()
|
||||
|
||||
def setTrailFadePercentage(self, trail):
|
||||
self._trailFadePercentage = trail
|
||||
|
||||
def setMinimumTrailOpacity(self, minimumTrailOpacity):
|
||||
self._minimumTrailOpacity = minimumTrailOpacity
|
||||
|
||||
def rotate(self):
|
||||
self._currentCounter += 1
|
||||
if self._currentCounter >= self._numberOfLines:
|
||||
self._currentCounter = 0
|
||||
self.update()
|
||||
|
||||
def updateSize(self):
|
||||
self.size = (self._innerRadius + self._lineLength) * 2
|
||||
self.setFixedSize(self.size, self.size)
|
||||
|
||||
def updateTimer(self):
|
||||
self._timer.setInterval(int(1000 / (self._numberOfLines * self._revolutionsPerSecond)))
|
||||
|
||||
def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines):
|
||||
distance = primary - current
|
||||
if distance < 0:
|
||||
distance += totalNrOfLines
|
||||
return distance
|
||||
|
||||
def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput):
|
||||
color = QColor(colorinput)
|
||||
if countDistance == 0:
|
||||
return color
|
||||
minAlphaF = minOpacity / 100.0
|
||||
distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0))
|
||||
if countDistance > distanceThreshold:
|
||||
color.setAlphaF(minAlphaF)
|
||||
else:
|
||||
alphaDiff = color.alphaF() - minAlphaF
|
||||
gradient = alphaDiff / float(distanceThreshold + 1)
|
||||
resultAlpha = color.alphaF() - gradient * countDistance
|
||||
# If alpha is out of bounds, clip it.
|
||||
resultAlpha = min(1.0, max(0.0, resultAlpha))
|
||||
color.setAlphaF(resultAlpha)
|
||||
return color
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = QApplication([])
|
||||
waiting_spinner = QtWaitingSpinner()
|
||||
waiting_spinner.show()
|
||||
waiting_spinner.start()
|
||||
app.exec()
|
Well, you could have just skipped wait in update_params and there should not be such drift