Compare commits

..

16 Commits

Author SHA1 Message Date
8db4867ebf PyThermostat: Improve pyproject metadata 2024-11-25 13:38:04 +08:00
130bde480e PyThermostat: Replace setup.py with pyproject.toml 2024-11-25 13:14:54 +08:00
36d80ebdff PyThermostat: Add entry points for runnables
Forms a more convienient interface.
2024-11-25 10:07:30 +08:00
09300b5d44 PyThermostat: Add main function to plot.py 2024-11-25 10:07:30 +08:00
9743dca775 PyThermostat: Move scripts into subfolder
As Thermostat Python scripts are not single-file Python modules and
should be packaged inside PyThermostat.
2024-11-25 10:07:20 +08:00
11131deda2 README: Add PID Output Clamping section
Explains the need of having separate "max_i_pos/output_max" and
"max_i_neg/output_min" values; They serve different purposes.
2024-11-20 08:02:07 +08:00
764774fbce PyThermostat: Remove report mode in autotune.py 2024-11-18 17:47:33 +08:00
4beeec6021 PyThermostat: Remove all references to Pytec 2024-11-18 17:34:39 +08:00
6b8a5f5bb8 Rename the Pytec library to PyThermostat
Pytec is a misnomer, as the Thermostat is not limited to just
controlling TEC modules. The library also interfaces with and controls
the Thermostat itself, and not the TEC module directly.

See M-Labs/thermostat#149 (comment)
2024-11-18 16:22:57 +08:00
8dd58b364d README: Fix limits section 2024-11-18 14:01:51 +08:00
ae0d593139 pytec: Stop using client report mode in plot.py
Report mode has been removed from the client, stop using it.
2024-11-18 13:57:54 +08:00
adc25c9b2a pytec: Add hardware testing script
Eases the process of testing the hardware.

See #143.
2024-11-18 10:31:56 +08:00
9af86be674 pytec: Remove artificial report mode in client
Encourage polling usage instead, as shown in example.
2024-11-16 13:11:59 +08:00
eabc7f6a12 flake: Register the pytec Python package 2024-11-11 17:11:37 +08:00
52e35d2a98 flake: Format with nixfmt-rfc-style
Also set up formatter so that `nix fmt` formats.
2024-11-04 18:38:08 +08:00
f1da910c11 pytec: Complete client
Expose all available Thermostat commands to the pytec client, and add
disconnect functionality.
2024-11-04 18:03:50 +08:00
19 changed files with 354 additions and 2476 deletions

View File

@ -67,19 +67,7 @@ On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit" openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit"
``` ```
## GUI Usage ## Network
A GUI has been developed for easy configuration and plotting of key parameters.
The Python GUI program is located at pytec/tec_qt.py.
The GUI is developed based on the Python library pyqtgraph. The GUI can be configured and launched automatically by running:
```
nix run .#thermostat_gui
```
## Command Line Usage
### Connecting ### Connecting
@ -201,31 +189,30 @@ Testing heat flow direction with a low set current is recommended before install
### Limits ### Limits
Each MAX1968 TEC driver has analog/PWM inputs for setting Each channel has maximum value settings, for setting
output limits. output limits.
Use the `output` command to see current settings and maximum values. Use the `output` command to see them.
| Limit | Unit | Description | | Limit | Unit | Description |
| --- | :---: | --- | | --- | :---: | --- |
| `max_v` | Volts | Maximum voltage | | `max_v` | Volts | Maximum voltage |
| `max_i_pos` | Amperes | Maximum positive current | | `max_i_pos` | Amperes | Maximum positive current |
| `max_i_neg` | Amperes | Maximum negative current | | `max_i_neg` | Amperes | Maximum negative current |
| `i_set` | Amperes | (Not a limit; Open-loop mode) |
Example: set the maximum voltage of channel 0 to 1.5 V. Example: set the maximum voltage of channel 0 to 1.5 V.
``` ```
output 0 max_v 1.5 output 0 max_v 1.5
``` ```
Example: set the maximum negative current of channel 0 to -3 A. Example: set the maximum negative current of channel 0 to -2 A.
``` ```
output 0 max_i_neg 3 output 0 max_i_neg 2
``` ```
Example: set the maximum positive current of channel 1 to 3 A. Example: set the maximum positive current of channel 1 to 2 A.
``` ```
output 0 max_i_pos 3 output 1 max_i_pos 2
``` ```
### Open-loop mode ### Open-loop mode
@ -252,6 +239,22 @@ of channel 0 to the PID algorithm:
output 0 pid output 0 pid
``` ```
### PID output clamping
It is possible to clamp the PID algorithm output independently of channel output limits. This is desirable when e.g. there is a need to keep the current value above a certain threshold in closed-loop mode.
Note that the actual output will still ultimately be limited by the `max_i_pos` and `max_i_neg` values.
Set PID maximum output of channel 0 to 1.5 A.
```
pid 0 output_max 1.5
```
Set PID minimum output of channel 0 to 0.1 A.
```
pid 0 output_min 0.1
```
## LED indicators ## LED indicators
| Name | Color | Meaning | | Name | Color | Meaning |

View File

@ -13,7 +13,7 @@ When tuning Thermostat PID parameters, it is helpful to view the temperature, PI
To use the Python real-time plotting utility, run To use the Python real-time plotting utility, run
```shell ```shell
python pytec/plot.py python pythermostat/pythermostat/plot.py
``` ```
![default view](./assets/default%20view.png) ![default view](./assets/default%20view.png)
@ -44,12 +44,12 @@ Below are some general guidelines for manually tuning PID loops. Note that every
## Auto Tuning ## Auto Tuning
A PID auto tuning utility is provided in the Pytec library. The auto tuning utility drives the the load to a controlled oscillation, observes the ultimate gain and oscillation period and calculates a set of PID parameters. A PID auto tuning utility is provided in the PyThermostat library. The auto tuning utility drives the the load to a controlled oscillation, observes the ultimate gain and oscillation period and calculates a set of PID parameters.
To run the auto tuning utility, run To run the auto tuning utility, run
```shell ```shell
python pytec/autotune.py python pythermostat/pythermostat/autotune.py
``` ```
After some time, the auto tuning utility will output the auto tuning results, below is a sample output After some time, the auto tuning utility will output the auto tuning results, below is a sample output

View File

@ -2,14 +2,22 @@
description = "Firmware for the Sinara 8451 Thermostat"; description = "Firmware for the Sinara 8451 Thermostat";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
inputs.rust-overlay = { inputs.rust-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = { self, nixpkgs, rust-overlay }: outputs =
{
self,
nixpkgs,
rust-overlay,
}:
let let
pkgs = import nixpkgs { system = "x86_64-linux"; overlays = [ (import rust-overlay) ]; }; pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [ (import rust-overlay) ];
};
rust = pkgs.rust-bin.stable."1.66.0".default.override { rust = pkgs.rust-bin.stable."1.66.0".default.override {
extensions = [ "rust-src" ]; extensions = [ "rust-src" ];
@ -25,7 +33,7 @@
version = "0.0.0"; version = "0.0.0";
src = self; src = self;
cargoLock = { cargoLock = {
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
outputHashes = { outputHashes = {
"stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o="; "stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
@ -50,35 +58,22 @@
auditable = false; auditable = false;
}; };
pglive = pkgs.python3Packages.buildPythonPackage rec { pythermostat = pkgs.python3Packages.buildPythonPackage {
pname = "pglive"; pname = "pythermostat";
version = "0.7.2";
format = "pyproject";
src = pkgs.fetchPypi {
inherit pname version;
sha256 = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A=";
};
buildInputs = [ pkgs.python3Packages.poetry-core ];
propagatedBuildInputs = [ pkgs.python3Packages.pyqtgraph pkgs.python3Packages.numpy ];
};
thermostat_gui = pkgs.python3Packages.buildPythonPackage rec {
pname = "thermostat_gui";
version = "0.0.0"; version = "0.0.0";
format = "pyproject"; format = "pyproject";
src = "${self}/pytec"; src = "${self}/pythermostat";
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; propagatedBuildInputs =
propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync pglive ]); with pkgs.python3Packages; [
numpy
dontWrapQtApps = true; matplotlib
postFixup = '' ];
wrapQtApp "$out/bin/tec_qt"
'';
}; };
in { in
{
packages.x86_64-linux = { packages.x86_64-linux = {
inherit thermostat thermostat_gui; inherit thermostat pythermostat;
default = thermostat; default = thermostat;
}; };
@ -88,12 +83,21 @@
devShells.x86_64-linux.default = pkgs.mkShellNoCC { devShells.x86_64-linux.default = pkgs.mkShellNoCC {
name = "thermostat-dev-shell"; name = "thermostat-dev-shell";
packages = with pkgs; [ packages =
rust llvm with pkgs;
openocd dfu-util rlwrap [
] ++ (with python3Packages; [ rust
numpy matplotlib pyqtgraph setuptools pyqt6 qasync pglive llvm
openocd
dfu-util
rlwrap
]
++ (with python3Packages; [
numpy
matplotlib
]); ]);
}; };
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-rfc-style;
}; };
} }

View File

@ -1,16 +0,0 @@
import asyncio
from pytec.aioclient import Client
async def main():
tec = Client()
await tec.start_session() #(host="192.168.1.26", port=23)
await tec.set_param("s-h", 1, "t0", 20)
print(await tec.get_output())
print(await tec.get_pid())
print(await tec.get_output())
print(await tec.get_postfilter())
print(await tec.get_b_parameter())
async for data in tec.report_mode():
print(data)
asyncio.run(main())

View File

@ -1,128 +0,0 @@
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from threading import Thread, Lock
from pytec.client import Client
TIME_WINDOW = 300.0
tec = Client()
target_temperature = tec.get_pid()[0]['target']
print("Channel 0 target temperature: {:.3f}".format(target_temperature))
class Series:
def __init__(self, conv=lambda x: x):
self.conv = conv
self.x_data = []
self.y_data = []
def append(self, x, y):
self.x_data.append(x)
self.y_data.append(self.conv(y))
def clip(self, min_x):
drop = 0
while drop < len(self.x_data) and self.x_data[drop] < min_x:
drop += 1
self.x_data = self.x_data[drop:]
self.y_data = self.y_data[drop:]
series = {
# 'adc': Series(),
# 'sens': Series(lambda x: x * 0.0001),
'temperature': Series(),
# 'i_set': Series(),
'pid_output': Series(),
# 'vref': Series(),
# 'dac_value': Series(),
# 'dac_feedback': Series(),
# 'i_tec': Series(),
'tec_i': Series(),
'tec_u_meas': Series(),
# 'interval': Series(),
}
series_lock = Lock()
quit = False
def recv_data(tec):
global last_packet_time
for data in tec.report_mode():
ch0 = data[0]
series_lock.acquire()
try:
for k, s in series.items():
if k in ch0:
v = ch0[k]
if type(v) is float:
s.append(ch0['time'], v)
finally:
series_lock.release()
if quit:
break
thread = Thread(target=recv_data, args=(tec,))
thread.start()
fig, ax = plt.subplots()
for k, s in series.items():
s.plot, = ax.plot([], [], label=k)
legend = ax.legend()
def animate(i):
min_x, max_x, min_y, max_y = None, None, None, None
series_lock.acquire()
try:
for k, s in series.items():
s.plot.set_data(s.x_data, s.y_data)
if len(s.y_data) > 0:
s.plot.set_label("{}: {:.3f}".format(k, s.y_data[-1]))
if len(s.x_data) > 0:
min_x_ = min(s.x_data)
if min_x is None:
min_x = min_x_
else:
min_x = min(min_x, min_x_)
max_x_ = max(s.x_data)
if max_x is None:
max_x = max_x_
else:
max_x = max(max_x, max_x_)
if len(s.y_data) > 0:
min_y_ = min(s.y_data)
if min_y is None:
min_y = min_y_
else:
min_y = min(min_y, min_y_)
max_y_ = max(s.y_data)
if max_y is None:
max_y = max_y_
else:
max_y = max(max_y, max_y_)
if min_x and max_x - TIME_WINDOW > min_x:
for s in series.values():
s.clip(max_x - TIME_WINDOW)
finally:
series_lock.release()
if min_x != max_x:
ax.set_xlim(min_x, max_x)
if min_y != max_y:
margin_y = 0.01 * (max_y - min_y)
ax.set_ylim(min_y - margin_y, max_y + margin_y)
global legend
legend.remove()
legend = ax.legend()
ani = animation.FuncAnimation(
fig, animate, interval=1, blit=False, save_count=50)
plt.show()
quit = True
thread.join()

View File

@ -1,281 +0,0 @@
import asyncio
import json
import logging
class CommandError(Exception):
pass
class StoppedConnecting(Exception):
pass
class Client:
def __init__(self):
self._reader = None
self._writer = None
self._connecting_task = None
self._command_lock = asyncio.Lock()
self._report_mode_on = False
self.timeout = None
async def start_session(self, host='192.168.1.26', port=23, timeout=None):
"""Start session to Thermostat at specified host and port.
Throws StoppedConnecting if disconnect was called while connecting.
Throws asyncio.TimeoutError if timeout was exceeded.
Example::
client = Client()
try:
await client.start_session()
except StoppedConnecting:
print("Stopped connecting")
"""
self._connecting_task = asyncio.create_task(
asyncio.wait_for(asyncio.open_connection(host, port), timeout)
)
self.timeout = timeout
try:
self._reader, self._writer = await self._connecting_task
except asyncio.CancelledError:
raise StoppedConnecting
finally:
self._connecting_task = None
await self._check_zero_limits()
def connecting(self):
"""Returns True if client is connecting"""
return self._connecting_task is not None
def connected(self):
"""Returns True if client is connected"""
return self._writer is not None
async def end_session(self):
"""End session to Thermostat if connected, cancel connection if connecting"""
if self._connecting_task is not None:
self._connecting_task.cancel()
if self._writer is None:
return
# Reader needn't be closed
self._writer.close()
await self._writer.wait_closed()
self._reader = None
self._writer = None
async def _check_zero_limits(self):
output_report = await self.get_output()
for output_channel in output_report:
for limit in ["max_i_neg", "max_i_pos", "max_v"]:
if output_channel[limit] == 0.0:
logging.warning("`{}` limit is set to zero on channel {}".format(limit, output_channel["channel"]))
async def _read_line(self):
# read 1 line
chunk = await asyncio.wait_for(self._reader.readline(), self.timeout) # Only wait for response until timeout
return chunk.decode('utf-8', errors='ignore')
async def _read_write(self, command):
self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8'))
await self._writer.drain()
return await self._read_line()
async def _command(self, *command):
async with self._command_lock:
# protect the read-write process from being cancelled midway
line = await asyncio.shield(self._read_write(command))
response = json.loads(line)
logging.debug(f"{command}: {response}")
if "error" in response:
raise CommandError(response["error"])
return response
async def _get_conf(self, topic):
result = [None, None]
for item in await self._command(topic):
result[int(item["channel"])] = item
return result
async def get_output(self):
"""Retrieve output limits for the TEC
Example::
[{'channel': 0,
'center': 'vref',
'i_set': -0.02002179650216762,
'max_i_neg': 2.0,
'max_v': : 3.988,
'max_i_pos': 2.0,
'polarity': 'normal'},
{'channel': 1,
'center': 'vref',
'i_set': -0.02002179650216762,
'max_i_neg': 2.0,
'max_v': : 3.988,
'max_i_pos': 2.0,
'polarity': 'normal'},
]
"""
return await self._get_conf("output")
async def get_pid(self):
"""Retrieve PID control state
Example::
[{'channel': 0,
'parameters': {
'kp': 10.0,
'ki': 0.02,
'kd': 0.0,
'output_min': 0.0,
'output_max': 3.0},
'target': 37.0},
{'channel': 1,
'parameters': {
'kp': 10.0,
'ki': 0.02,
'kd': 0.0,
'output_min': 0.0,
'output_max': 3.0},
'target': 36.5}]
"""
return await self._get_conf("pid")
async def get_b_parameter(self):
"""Retrieve B-Parameter equation parameters for resistance to temperature conversion
Example::
[{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0},
{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}]
"""
return await self._get_conf("b-p")
async def get_postfilter(self):
"""Retrieve DAC postfilter configuration
Example::
[{'rate': None, 'channel': 0},
{'rate': 21.25, 'channel': 1}]
"""
return await self._get_conf("postfilter")
async def get_fan(self):
"""Get Thermostat current fan settings"""
return await self._command("fan")
async def report(self):
"""Obtain one-time report on measurement values"""
return await self._command("report")
async def report_mode(self):
"""Start reporting measurement values
Example of yielded data::
{'channel': 0,
'time': 2302524,
'adc': 0.6199188965423515,
'sens': 6138.519310282602,
'temperature': 36.87032392655527,
'pid_engaged': True,
'i_set': 2.0635816680889123,
'vref': 1.494,
'dac_value': 2.527790834044456,
'dac_feedback': 2.523,
'i_tec': 2.331,
'tec_i': 2.0925,
'tec_u_meas': 2.5340000000000003,
'pid_output': 2.067581958092247}
"""
await self._command("report mode", "on")
self._report_mode_on = True
while self._report_mode_on:
async with self._command_lock:
line = await self._read_line()
if not line:
break
try:
yield json.loads(line)
except json.decoder.JSONDecodeError:
pass
await self._command("report mode", "off")
def stop_report_mode(self):
self._report_mode_on = False
async def set_param(self, topic, channel, field="", value=""):
"""Set configuration parameters
Examples::
await tec.set_param("output", 0, "max_v", 2.0)
await tec.set_param("pid", 1, "output_max", 2.5)
await tec.set_param("s-h", 0, "t0", 20.0)
await tec.set_param("center", 0, "vref")
await tec.set_param("postfilter", 1, 21)
See the firmware's README.md for a full list.
"""
if type(value) is float:
value = "{:f}".format(value)
if type(value) is not str:
value = str(value)
await self._command(topic, str(channel), field, value)
async def set_fan(self, power="auto"):
"""Set fan power"""
await self._command("fan", str(power))
async def set_fcurve(self, a=1.0, b=0.0, c=0.0):
"""Set fan curve"""
await self._command("fcurve", str(a), str(b), str(c))
async def power_up(self, channel, target):
"""Start closed-loop mode"""
await self.set_param("pid", channel, "target", value=target)
await self.set_param("output", channel, "pid")
async def save_config(self, channel=""):
"""Save current configuration to EEPROM"""
await self._command("save", str(channel))
async def load_config(self, channel=""):
"""Load current configuration from EEPROM"""
await self._command("load", str(channel))
if channel == "":
await self._read_line() # Read the extra {}
async def hw_rev(self):
"""Get Thermostat hardware revision"""
return await self._command("hwrev")
async def reset(self):
"""Reset the Thermostat
The client is disconnected as the TCP session is terminated.
"""
async with self._command_lock:
self._writer.write("reset\n".encode('utf-8'))
await self._writer.drain()
await self.end_session()
async def dfu(self):
"""Put the Thermostat in DFU update mode
The client is disconnected as the Thermostat stops responding to
TCP commands in DFU update mode. The only way to exit it is by
power-cycling.
"""
async with self._command_lock:
self._writer.write("dfu\n".encode('utf-8'))
await self._writer.drain()
await self.end_session()
async def ipv4(self):
"""Get the IPv4 settings of the Thermostat"""
return await self._command('ipv4')

View File

@ -1,18 +0,0 @@
from setuptools import setup, find_packages
setup(
name="pytec",
version="0.0",
author="M-Labs",
url="https://git.m-labs.hk/M-Labs/thermostat",
description="Control TEC",
license="GPLv3",
install_requires=["setuptools"],
packages=find_packages(),
entry_points={
"gui_scripts": [
"tec_qt = tec_qt:main",
]
},
py_modules=['tec_qt', 'ui_tec_qt', 'autotune', 'waitingspinnerwidget'],
)

View File

@ -1,903 +0,0 @@
from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType
import pyqtgraph as pg
pg.setConfigOptions(antialias=True)
from pglive.sources.data_connector import DataConnector
from pglive.kwargs import Axis
from pglive.sources.live_plot import LiveLinePlot
from pglive.sources.live_plot_widget import LivePlotWidget
from pglive.sources.live_axis import LiveAxis
import sys
import argparse
import logging
import asyncio
from pytec.aioclient import Client, StoppedConnecting
import qasync
from qasync import asyncSlot, asyncClose
from autotune import PIDAutotune, PIDAutotuneState
# pyuic6 -x tec_qt.ui -o ui_tec_qt.py
from ui_tec_qt import Ui_MainWindow
"""Number of channels provided by the Thermostat"""
NUM_CHANNELS: int = 2
def get_argparser():
parser = argparse.ArgumentParser(description="ARTIQ master")
parser.add_argument("--connect", default=None, action="store_true",
help="Automatically connect to the specified Thermostat in IP:port format")
parser.add_argument('IP', metavar="ip", default=None, nargs='?')
parser.add_argument('PORT', metavar="port", default=None, nargs='?')
parser.add_argument("-l", "--log", dest="logLevel", choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
help="Set the logging level")
return parser
class MutexParameter(pTypes.ListParameter):
"""
Mutually exclusive parameter where only one of its children is visible at a time, list selectable.
The ordering of the list items determines which children will be visible.
"""
def __init__(self, **opts):
super().__init__(**opts)
self.sigValueChanged.connect(self.show_chosen_child)
self.sigValueChanged.emit(self, self.opts['value'])
def _get_param_from_value(self, value):
if isinstance(self.opts['limits'], dict):
values_list = list(self.opts['limits'].values())
else:
values_list = self.opts['limits']
return self.children()[values_list.index(value)]
@pyqtSlot(object, object)
def show_chosen_child(self, value):
for param in self.children():
param.hide()
child_to_show = self._get_param_from_value(value.value())
child_to_show.show()
if child_to_show.opts.get('triggerOnShow', None):
child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value())
registerParameterType('mutex', MutexParameter)
class WrappedClient(QObject, Client):
connection_error = pyqtSignal()
async def _read_line(self):
try:
return await super()._read_line()
except (OSError, TimeoutError, 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._report_mode_task = None
self._poll_for_report = True
super().__init__(parent)
async def run(self):
loop = asyncio.get_running_loop()
while True:
time = loop.time()
await self.update_params()
await asyncio.sleep(self._update_s - (loop.time() - time))
async def update_params(self):
self.fan_update.emit(await self._client.get_fan())
self.pwm_update.emit(await self._client.get_pwm())
if self._poll_for_report:
self.report_update.emit(await self._client.report())
self.pid_update.emit(await self._client.get_pid())
self.thermistor_update.emit(await self._client.get_steinhart_hart())
self.postfilter_update.emit(await self._client.get_postfilter())
def start_watching(self):
self._watch_task = asyncio.create_task(self.run())
@pyqtSlot()
def stop_watching(self):
if self._watch_task is not None:
self._watch_task.cancel()
self._watch_task = None
async def set_report_mode(self, enabled: bool):
self._poll_for_report = not enabled
if enabled:
self._report_mode_task = asyncio.create_task(self.report_mode())
else:
self._client.stop_report_mode()
if self._report_mode_task is not None:
await self._report_mode_task
self._report_mode_task = None
async def report_mode(self):
async for report in self._client.report_mode():
self.report_update.emit(report)
@pyqtSlot(float)
def set_update_s(self, update_s):
self._update_s = update_s
class ChannelGraphs:
"""Manager of a channel's two graphs and their elements."""
"""The maximum number of sample points to store."""
DEFAULT_MAX_SAMPLES = 1000
def __init__(self, t_widget, i_widget):
self._t_widget = t_widget
self._i_widget = i_widget
self._t_plot = LiveLinePlot()
self._i_plot = LiveLinePlot(name="Measured")
self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen('r'))
self._t_line = self._t_widget.getPlotItem().addLine(label='{value} °C')
self._t_line.setVisible(False)
self._t_setpoint_plot = LiveLinePlot() # Hack for keeping setpoint line in plot range
for graph in t_widget, i_widget:
time_axis = LiveAxis('bottom', text="Time since Thermostat reset", **{Axis.TICK_FORMAT: Axis.DURATION})
time_axis.showLabel()
graph.setAxisItems({'bottom': time_axis})
graph.add_crosshair(pg.mkPen(color='red', width=1), {'color': 'green'})
# Enable linking of axes in the graph widget's context menu
graph.register(graph.getPlotItem().titleLabel.text) # Slight hack getting the title
temperature_axis = LiveAxis('left', text="Temperature", units="°C")
temperature_axis.showLabel()
t_widget.setAxisItems({'left': temperature_axis})
current_axis = LiveAxis('left', text="Current", units="A")
current_axis.showLabel()
i_widget.setAxisItems({'left': current_axis})
i_widget.addLegend(brush=(50, 50, 200, 150))
t_widget.addItem(self._t_plot)
t_widget.addItem(self._t_setpoint_plot)
i_widget.addItem(self._i_plot)
i_widget.addItem(self._iset_plot)
self.t_connector = DataConnector(self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1)
self.i_connector = DataConnector(self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.iset_connector = DataConnector(self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.max_samples = self.DEFAULT_MAX_SAMPLES
def plot_append(self, report):
temperature = report['temperature']
current = report['tec_i']
iset = report['i_set']
time = report['time']
if temperature is not None:
self.t_connector.cb_append_data_point(temperature, time)
if self._t_line.isVisible():
self.t_setpoint_connector.cb_append_data_point(self._t_line.value(), time)
else:
self.t_setpoint_connector.cb_append_data_point(temperature, time)
if current is not None:
self.i_connector.cb_append_data_point(current, time)
self.iset_connector.cb_append_data_point(iset, time)
def clear(self):
for connector in self.t_connector, self.i_connector, self.iset_connector:
connector.clear()
def set_t_line(self, temp=None, visible=None):
if visible is not None:
self._t_line.setVisible(visible)
if temp is not None:
self._t_line.setValue(temp)
# PyQtGraph normally does not update this text when the line
# is not visible, so make sure that the temperature label
# gets updated always, and doesn't stay at an old value.
self._t_line.label.setText(f"{temp} °C")
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
"""The maximum number of sample points to store."""
DEFAULT_MAX_SAMPLES = 1000
"""Thermostat parameters that are particular to a channel"""
THERMOSTAT_PARAMETERS = [[
{'name': 'Temperature', 'type': 'float', 'format': '{value:.4f} °C', 'readonly': True},
{'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True},
{'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [
{'name': 'Control Method', 'type': 'mutex', 'limits': ['Constant Current', 'Temperature PID'],
'activaters': [None, ('pwm', ch, 'pid')], 'children': [
{'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True,
'decimals': 6, 'suffix': 'mA', 'param': ('pwm', ch, 'i_set')},
{'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300),
'format': '{value:.4f} °C', 'param': ('pid', ch, 'target')},
]},
{'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Max Cooling Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000),
'suffix': 'mA', 'param': ('pwm', ch, 'max_i_pos')},
{'name': 'Max Heating Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000),
'suffix': 'mA', 'param': ('pwm', ch, 'max_i_neg')},
{'name': 'Max Voltage Difference', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True,
'suffix': 'V', 'param': ('pwm', ch, 'max_v')},
]}
]},
{'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100),
'format': '{value:.4f} °C', 'param': ('s-h', ch, 't0')},
{'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω',
'param': ('s-h', ch, 'r0')},
{'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': ('s-h', ch, 'b')},
{'name': 'Postfilter Rate', 'type': 'list', 'value': 16.67, 'param': ('postfilter', ch, 'rate'),
'limits': {'Off': None, '16.67 Hz': 16.67, '20 Hz': 20.0, '21.25 Hz': 21.25, '27 Hz': 27.0}},
]},
{'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': ('pid', ch, 'kp')},
{'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': ('pid', ch, 'ki')},
{'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': ('pid', ch, 'kd')},
{'name': "PID Output Clamping", 'expanded': True, 'type': 'group', 'children': [
{'name': 'Minimum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_min')},
{'name': 'Maximum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_max')},
]},
{'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'format': '{value:.4f} °C'},
{'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'},
{'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'prefix': '±', 'format': '{value:.4f} °C'},
{'name': 'Run', 'type': 'action', 'tip': 'Run'},
]},
]},
{'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'},
{'name': 'Load from flash', 'type': 'action', 'tip': 'Load config from flash'}
] for ch in range(NUM_CHANNELS)]
def __init__(self, args):
super().__init__()
self.setupUi(self)
self.ch0_t_graph.setTitle("Channel 0 Temperature")
self.ch0_i_graph.setTitle("Channel 0 Current")
self.ch1_t_graph.setTitle("Channel 1 Temperature")
self.ch1_i_graph.setTitle("Channel 1 Current")
self.max_samples = self.DEFAULT_MAX_SAMPLES
self._set_up_connection_menu()
self._set_up_thermostat_menu()
self._set_up_plot_menu()
self.client = WrappedClient(self)
self.client.connection_error.connect(self.bail)
self.client_watcher = ClientWatcher(self, self.client, self.report_refresh_spin.value())
self.client_watcher.fan_update.connect(self.fan_update)
self.client_watcher.report_update.connect(self.update_report)
self.client_watcher.pid_update.connect(self.update_pid)
self.client_watcher.pwm_update.connect(self.update_pwm)
self.client_watcher.thermistor_update.connect(self.update_thermistor)
self.client_watcher.postfilter_update.connect(self.update_postfilter)
self.report_apply_btn.clicked.connect(
lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value())
)
self.params = [
Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch])
for ch in range(NUM_CHANNELS)
]
self._set_param_tree()
self.channel_graphs = [
ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph'))
for ch in range(NUM_CHANNELS)
]
self.autotuners = [
PIDAutotune(25)
for _ in range(NUM_CHANNELS)
]
self.loading_spinner.hide()
self.hw_rev_data = None
if args.connect:
if args.IP:
self.host_set_line.setText(args.IP)
if args.PORT:
self.port_set_spin.setValue(int(args.PORT))
self.connect_btn.click()
def _set_up_connection_menu(self):
self.connection_menu = QtWidgets.QMenu()
self.connection_menu.setTitle('Connection Settings')
self.host_set_line = QtWidgets.QLineEdit()
self.host_set_line.setMinimumSize(QtCore.QSize(160, 0))
self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215))
self.host_set_line.setMaxLength(15)
self.host_set_line.setClearButtonEnabled(True)
def connect_on_enter_press():
self.connect_btn.click()
self.connection_menu.hide()
self.host_set_line.returnPressed.connect(connect_on_enter_press)
self.host_set_line.setText("192.168.1.26")
self.host_set_line.setPlaceholderText("IP for the Thermostat")
host = QtWidgets.QWidgetAction(self.connection_menu)
host.setDefaultWidget(self.host_set_line)
self.connection_menu.addAction(host)
self.connection_menu.host = host
self.port_set_spin = QtWidgets.QSpinBox()
self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0))
self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215))
self.port_set_spin.setMaximum(65535)
self.port_set_spin.setValue(23)
def connect_only_if_enter_pressed():
if not self.port_set_spin.hasFocus(): # Don't connect if the spinbox only lost focus
return;
connect_on_enter_press()
self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
port = QtWidgets.QWidgetAction(self.connection_menu)
port.setDefaultWidget(self.port_set_spin)
self.connection_menu.addAction(port)
self.connection_menu.port = port
self.exit_button = QtWidgets.QPushButton()
self.exit_button.setText("Exit GUI")
self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
exit_action = QtWidgets.QWidgetAction(self.exit_button)
exit_action.setDefaultWidget(self.exit_button)
self.connection_menu.addAction(exit_action)
self.connection_menu.exit_action = exit_action
self.connect_btn.setMenu(self.connection_menu)
def _set_up_thermostat_menu(self):
self.thermostat_menu = QtWidgets.QMenu()
self.thermostat_menu.setTitle('Thermostat settings')
self.fan_group = QtWidgets.QWidget()
self.fan_group.setEnabled(False)
self.fan_group.setMinimumSize(QtCore.QSize(40, 0))
self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group)
self.fan_layout.setSpacing(9)
self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group)
self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0))
self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215))
self.fan_lbl.setBaseSize(QtCore.QSize(40, 0))
self.fan_layout.addWidget(self.fan_lbl)
self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group)
self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0))
self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215))
self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0))
self.fan_power_slider.setRange(1, 100)
self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.fan_layout.addWidget(self.fan_power_slider)
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0))
self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215))
self.fan_layout.addWidget(self.fan_auto_box)
self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
self.fan_layout.addWidget(self.fan_pwm_warning)
self.fan_power_slider.valueChanged.connect(self.fan_set)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set)
self.fan_lbl.setToolTip("Adjust the fan")
self.fan_lbl.setText("Fan:")
self.fan_auto_box.setText("Auto")
fan = QtWidgets.QWidgetAction(self.thermostat_menu)
fan.setDefaultWidget(self.fan_group)
self.thermostat_menu.addAction(fan)
self.thermostat_menu.fan = fan
@asyncSlot(bool)
async def reset_thermostat(_):
await self._on_connection_changed(False)
await self.client.reset()
await asyncio.sleep(0.1) # Wait for the reset to start
self.connect_btn.click() # Reconnect
self.actionReset.triggered.connect(reset_thermostat)
self.thermostat_menu.addAction(self.actionReset)
@asyncSlot(bool)
async def dfu_mode(_):
await self._on_connection_changed(False)
await self.client.dfu()
# TODO: add a firmware flashing GUI?
self.actionEnter_DFU_Mode.triggered.connect(dfu_mode)
self.thermostat_menu.addAction(self.actionEnter_DFU_Mode)
@asyncSlot(bool)
async def network_settings(_):
ask_network = QtWidgets.QInputDialog(self)
ask_network.setWindowTitle("Network Settings")
ask_network.setLabelText("Set the Thermostat's IPv4 address, netmask and gateway (optional)")
ask_network.setTextValue((await self.client.ipv4())['addr'])
@pyqtSlot(str)
def set_ipv4(ipv4_settings):
sure = QtWidgets.QMessageBox(self)
sure.setWindowTitle("Set network?")
sure.setText(f"Setting this as network and disconnecting:<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>
<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
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.report_group.setEnabled(result)
self.thermostat_settings.setEnabled(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()
# 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:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
icon = self.style().standardIcon(pixmapi)
self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
self.fan_pwm_warning.setToolTip("Throttling the fan (not recommended on this hardware rev)")
else:
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.fan_pwm_warning.setToolTip("")
def _status(self, hw_rev_d: dict):
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"])
@pyqtSlot(dict)
def fan_update(self, fan_settings: dict):
logging.debug(fan_settings)
if fan_settings is None:
return
with QSignalBlocker(self.fan_power_slider):
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.hw_rev_data["settings"]["fan_pwm_recommended"]:
self._set_fan_pwm_warning()
@asyncSlot(int)
async def fan_set(self, value):
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.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.client.connected():
return
if enabled:
await self.client.set_fan("auto")
self.fan_update(await self.client.get_fan())
else:
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):
await self.bail()
@asyncSlot()
async def on_connect_btn_clicked(self):
host, port = self.host_set_line.text(), self.port_set_spin.value()
try:
if not (self.client.connecting() or self.client.connected()):
self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop")
self.host_set_line.setEnabled(False)
self.port_set_spin.setEnabled(False)
try:
await self.client.start_session(host=host, port=port, timeout=30)
except StoppedConnecting:
return
await self._on_connection_changed(True)
else:
await self.bail()
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):
"""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):
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(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"]
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():
args = get_argparser().parse_args()
if args.logLevel:
logging.basicConfig(level=getattr(logging, args.logLevel))
app_quit_event = asyncio.Event()
app = QtWidgets.QApplication.instance()
app.aboutToQuit.connect(app_quit_event.set)
main_window = MainWindow(args)
main_window.show()
await app_quit_event.wait()
def main():
qasync.run(coro_main())
if __name__ == '__main__':
main()

View File

@ -1,544 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1280</width>
<height>720</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>1280</width>
<height>720</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>3840</width>
<height>2160</height>
</size>
</property>
<property name="windowTitle">
<string>Thermostat Control Panel</string>
</property>
<property name="windowIcon">
<iconset>
<normaloff>thermostat-icon-640x640.png</normaloff>thermostat-icon-640x640.png</iconset>
</property>
<widget class="QWidget" name="main_widget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>1</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<property name="spacing">
<number>3</number>
</property>
<item row="0" column="1">
<layout class="QVBoxLayout" name="main_layout">
<property name="spacing">
<number>0</number>
</property>
<item>
<widget class="QFrame" name="graph_group">
<property name="enabled">
<bool>false</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>1</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QGridLayout" name="graphs_layout" rowstretch="1,1" columnstretch="1,1,1" rowminimumheight="100,100" columnminimumwidth="100,100,100">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<property name="spacing">
<number>2</number>
</property>
<item row="1" column="0">
<widget class="ParameterTree" name="ch1_tree" native="true"/>
</item>
<item row="0" column="0">
<widget class="ParameterTree" name="ch0_tree" native="true"/>
</item>
<item row="1" column="1">
<widget class="LivePlotWidget" name="ch1_t_graph" native="true"/>
</item>
<item row="0" column="1">
<widget class="LivePlotWidget" name="ch0_t_graph" native="true"/>
</item>
<item row="0" column="2">
<widget class="LivePlotWidget" name="ch0_i_graph" native="true"/>
</item>
<item row="1" column="2">
<widget class="LivePlotWidget" name="ch1_i_graph" native="true"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="bottom_settings_group">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>40</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>40</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>3</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<layout class="QHBoxLayout" name="settings_layout">
<item>
<widget class="QToolButton" name="connect_btn">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<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>
<widget class="QLabel" name="status_lbl">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>240</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>120</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>120</width>
<height>50</height>
</size>
</property>
<property name="text">
<string>Disconnected</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="thermostat_settings">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string notr="true">⚙</string>
</property>
<property name="popupMode">
<enum>QToolButton::InstantPopup</enum>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="plot_settings">
<property name="toolTip">
<string>Plot Settings</string>
</property>
<property name="text">
<string>📉</string>
</property>
<property name="popupMode">
<enum>QToolButton::InstantPopup</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="limits_warning">
<property name="toolTipDuration">
<number>1000000000</number>
</property>
</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>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QWidget" name="report_group" native="true">
<property name="enabled">
<bool>false</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>40</width>
<height>0</height>
</size>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<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="report_layout" stretch="0,1,1,1">
<property name="spacing">
<number>6</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="report_lbl">
<property name="text">
<string>Poll every: </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QDoubleSpinBox" name="report_refresh_spin">
<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="baseSize">
<size>
<width>70</width>
<height>0</height>
</size>
</property>
<property name="suffix">
<string> s</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>0.100000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="stepType">
<enum>QAbstractSpinBox::AdaptiveDecimalStepType</enum>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="report_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Report</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="report_apply_btn">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</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>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/>
</ui>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

View File

@ -1,268 +0,0 @@
# Form implementation generated from reading ui file 'tec_qt.ui'
#
# Created by: PyQt6 UI code generator 6.5.2
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(1280, 720)
MainWindow.setMinimumSize(QtCore.QSize(1280, 720))
MainWindow.setMaximumSize(QtCore.QSize(3840, 2160))
icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap("thermostat-icon-640x640.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
MainWindow.setWindowIcon(icon)
self.main_widget = QtWidgets.QWidget(parent=MainWindow)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(1)
sizePolicy.setHeightForWidth(self.main_widget.sizePolicy().hasHeightForWidth())
self.main_widget.setSizePolicy(sizePolicy)
self.main_widget.setObjectName("main_widget")
self.gridLayout_2 = QtWidgets.QGridLayout(self.main_widget)
self.gridLayout_2.setContentsMargins(3, 3, 3, 3)
self.gridLayout_2.setSpacing(3)
self.gridLayout_2.setObjectName("gridLayout_2")
self.main_layout = QtWidgets.QVBoxLayout()
self.main_layout.setSpacing(0)
self.main_layout.setObjectName("main_layout")
self.graph_group = QtWidgets.QFrame(parent=self.main_widget)
self.graph_group.setEnabled(False)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(1)
sizePolicy.setHeightForWidth(self.graph_group.sizePolicy().hasHeightForWidth())
self.graph_group.setSizePolicy(sizePolicy)
self.graph_group.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.graph_group.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.graph_group.setObjectName("graph_group")
self.graphs_layout = QtWidgets.QGridLayout(self.graph_group)
self.graphs_layout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetDefaultConstraint)
self.graphs_layout.setContentsMargins(3, 3, 3, 3)
self.graphs_layout.setSpacing(2)
self.graphs_layout.setObjectName("graphs_layout")
self.ch1_tree = ParameterTree(parent=self.graph_group)
self.ch1_tree.setObjectName("ch1_tree")
self.graphs_layout.addWidget(self.ch1_tree, 1, 0, 1, 1)
self.ch0_tree = ParameterTree(parent=self.graph_group)
self.ch0_tree.setObjectName("ch0_tree")
self.graphs_layout.addWidget(self.ch0_tree, 0, 0, 1, 1)
self.ch1_t_graph = LivePlotWidget(parent=self.graph_group)
self.ch1_t_graph.setObjectName("ch1_t_graph")
self.graphs_layout.addWidget(self.ch1_t_graph, 1, 1, 1, 1)
self.ch0_t_graph = LivePlotWidget(parent=self.graph_group)
self.ch0_t_graph.setObjectName("ch0_t_graph")
self.graphs_layout.addWidget(self.ch0_t_graph, 0, 1, 1, 1)
self.ch0_i_graph = LivePlotWidget(parent=self.graph_group)
self.ch0_i_graph.setObjectName("ch0_i_graph")
self.graphs_layout.addWidget(self.ch0_i_graph, 0, 2, 1, 1)
self.ch1_i_graph = LivePlotWidget(parent=self.graph_group)
self.ch1_i_graph.setObjectName("ch1_i_graph")
self.graphs_layout.addWidget(self.ch1_i_graph, 1, 2, 1, 1)
self.graphs_layout.setColumnMinimumWidth(0, 100)
self.graphs_layout.setColumnMinimumWidth(1, 100)
self.graphs_layout.setColumnMinimumWidth(2, 100)
self.graphs_layout.setRowMinimumHeight(0, 100)
self.graphs_layout.setRowMinimumHeight(1, 100)
self.graphs_layout.setColumnStretch(0, 1)
self.graphs_layout.setColumnStretch(1, 1)
self.graphs_layout.setColumnStretch(2, 1)
self.graphs_layout.setRowStretch(0, 1)
self.graphs_layout.setRowStretch(1, 1)
self.main_layout.addWidget(self.graph_group)
self.bottom_settings_group = QtWidgets.QFrame(parent=self.main_widget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.bottom_settings_group.sizePolicy().hasHeightForWidth())
self.bottom_settings_group.setSizePolicy(sizePolicy)
self.bottom_settings_group.setMinimumSize(QtCore.QSize(0, 40))
self.bottom_settings_group.setMaximumSize(QtCore.QSize(16777215, 40))
self.bottom_settings_group.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.bottom_settings_group.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.bottom_settings_group.setObjectName("bottom_settings_group")
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.bottom_settings_group)
self.horizontalLayout_2.setContentsMargins(3, 3, 3, 3)
self.horizontalLayout_2.setSpacing(3)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.settings_layout = QtWidgets.QHBoxLayout()
self.settings_layout.setObjectName("settings_layout")
self.connect_btn = QtWidgets.QToolButton(parent=self.bottom_settings_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.connect_btn.sizePolicy().hasHeightForWidth())
self.connect_btn.setSizePolicy(sizePolicy)
self.connect_btn.setMinimumSize(QtCore.QSize(100, 0))
self.connect_btn.setMaximumSize(QtCore.QSize(100, 16777215))
self.connect_btn.setBaseSize(QtCore.QSize(100, 0))
self.connect_btn.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup)
self.connect_btn.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonFollowStyle)
self.connect_btn.setObjectName("connect_btn")
self.settings_layout.addWidget(self.connect_btn)
self.status_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.status_lbl.sizePolicy().hasHeightForWidth())
self.status_lbl.setSizePolicy(sizePolicy)
self.status_lbl.setMinimumSize(QtCore.QSize(240, 0))
self.status_lbl.setMaximumSize(QtCore.QSize(120, 16777215))
self.status_lbl.setBaseSize(QtCore.QSize(120, 50))
self.status_lbl.setObjectName("status_lbl")
self.settings_layout.addWidget(self.status_lbl)
self.thermostat_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group)
self.thermostat_settings.setEnabled(False)
self.thermostat_settings.setText("")
self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup)
self.thermostat_settings.setObjectName("thermostat_settings")
self.settings_layout.addWidget(self.thermostat_settings)
self.plot_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group)
self.plot_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup)
self.plot_settings.setObjectName("plot_settings")
self.settings_layout.addWidget(self.plot_settings)
self.limits_warning = QtWidgets.QLabel(parent=self.bottom_settings_group)
self.limits_warning.setToolTipDuration(1000000000)
self.limits_warning.setObjectName("limits_warning")
self.settings_layout.addWidget(self.limits_warning)
self.background_task_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group)
self.background_task_lbl.setObjectName("background_task_lbl")
self.settings_layout.addWidget(self.background_task_lbl)
self.loading_spinner = QtWaitingSpinner(parent=self.bottom_settings_group)
self.loading_spinner.setObjectName("loading_spinner")
self.settings_layout.addWidget(self.loading_spinner)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.settings_layout.addItem(spacerItem)
self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group)
self.report_group.setEnabled(False)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.report_group.sizePolicy().hasHeightForWidth())
self.report_group.setSizePolicy(sizePolicy)
self.report_group.setMinimumSize(QtCore.QSize(40, 0))
self.report_group.setObjectName("report_group")
self.horizontalLayout_4 = QtWidgets.QHBoxLayout(self.report_group)
self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout_4.setSpacing(0)
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
self.report_layout = QtWidgets.QHBoxLayout()
self.report_layout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetDefaultConstraint)
self.report_layout.setContentsMargins(0, -1, -1, -1)
self.report_layout.setSpacing(6)
self.report_layout.setObjectName("report_layout")
self.report_lbl = QtWidgets.QLabel(parent=self.report_group)
self.report_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.report_lbl.setObjectName("report_lbl")
self.report_layout.addWidget(self.report_lbl)
self.report_refresh_spin = QtWidgets.QDoubleSpinBox(parent=self.report_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.report_refresh_spin.sizePolicy().hasHeightForWidth())
self.report_refresh_spin.setSizePolicy(sizePolicy)
self.report_refresh_spin.setMinimumSize(QtCore.QSize(70, 0))
self.report_refresh_spin.setMaximumSize(QtCore.QSize(70, 16777215))
self.report_refresh_spin.setBaseSize(QtCore.QSize(70, 0))
self.report_refresh_spin.setDecimals(1)
self.report_refresh_spin.setMinimum(0.1)
self.report_refresh_spin.setSingleStep(0.1)
self.report_refresh_spin.setStepType(QtWidgets.QAbstractSpinBox.StepType.AdaptiveDecimalStepType)
self.report_refresh_spin.setProperty("value", 1.0)
self.report_refresh_spin.setObjectName("report_refresh_spin")
self.report_layout.addWidget(self.report_refresh_spin)
self.report_box = QtWidgets.QCheckBox(parent=self.report_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.report_box.sizePolicy().hasHeightForWidth())
self.report_box.setSizePolicy(sizePolicy)
self.report_box.setMaximumSize(QtCore.QSize(80, 16777215))
self.report_box.setBaseSize(QtCore.QSize(80, 0))
self.report_box.setObjectName("report_box")
self.report_layout.addWidget(self.report_box)
self.report_apply_btn = QtWidgets.QPushButton(parent=self.report_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.report_apply_btn.sizePolicy().hasHeightForWidth())
self.report_apply_btn.setSizePolicy(sizePolicy)
self.report_apply_btn.setMinimumSize(QtCore.QSize(80, 0))
self.report_apply_btn.setMaximumSize(QtCore.QSize(80, 16777215))
self.report_apply_btn.setBaseSize(QtCore.QSize(80, 0))
self.report_apply_btn.setObjectName("report_apply_btn")
self.report_layout.addWidget(self.report_apply_btn)
self.report_layout.setStretch(1, 1)
self.report_layout.setStretch(2, 1)
self.report_layout.setStretch(3, 1)
self.horizontalLayout_4.addLayout(self.report_layout)
self.settings_layout.addWidget(self.report_group)
self.horizontalLayout_2.addLayout(self.settings_layout)
self.main_layout.addWidget(self.bottom_settings_group)
self.gridLayout_2.addLayout(self.main_layout, 0, 1, 1, 1)
MainWindow.setCentralWidget(self.main_widget)
self.actionReset = QtGui.QAction(parent=MainWindow)
self.actionReset.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionReset.setObjectName("actionReset")
self.actionEnter_DFU_Mode = QtGui.QAction(parent=MainWindow)
self.actionEnter_DFU_Mode.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionEnter_DFU_Mode.setObjectName("actionEnter_DFU_Mode")
self.actionNetwork_Settings = QtGui.QAction(parent=MainWindow)
self.actionNetwork_Settings.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionNetwork_Settings.setObjectName("actionNetwork_Settings")
self.actionAbout_Thermostat = QtGui.QAction(parent=MainWindow)
self.actionAbout_Thermostat.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionAbout_Thermostat.setObjectName("actionAbout_Thermostat")
self.actionLoad_all_configs = QtGui.QAction(parent=MainWindow)
self.actionLoad_all_configs.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionLoad_all_configs.setObjectName("actionLoad_all_configs")
self.actionSave_all_configs = QtGui.QAction(parent=MainWindow)
self.actionSave_all_configs.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionSave_all_configs.setObjectName("actionSave_all_configs")
self.retranslateUi(MainWindow)
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel"))
self.connect_btn.setText(_translate("MainWindow", "Connect"))
self.status_lbl.setText(_translate("MainWindow", "Disconnected"))
self.plot_settings.setToolTip(_translate("MainWindow", "Plot Settings"))
self.plot_settings.setText(_translate("MainWindow", "📉"))
self.background_task_lbl.setText(_translate("MainWindow", "Ready."))
self.report_lbl.setText(_translate("MainWindow", "Poll every: "))
self.report_refresh_spin.setSuffix(_translate("MainWindow", " s"))
self.report_box.setText(_translate("MainWindow", "Report"))
self.report_apply_btn.setText(_translate("MainWindow", "Apply"))
self.actionReset.setText(_translate("MainWindow", "Reset"))
self.actionReset.setToolTip(_translate("MainWindow", "Reset the Thermostat"))
self.actionEnter_DFU_Mode.setText(_translate("MainWindow", "Enter DFU Mode"))
self.actionEnter_DFU_Mode.setToolTip(_translate("MainWindow", "Reset thermostat and enter USB device firmware update (DFU) mode"))
self.actionNetwork_Settings.setText(_translate("MainWindow", "Network Settings"))
self.actionNetwork_Settings.setToolTip(_translate("MainWindow", "Configure IPv4 address, netmask length, and optional default gateway"))
self.actionAbout_Thermostat.setText(_translate("MainWindow", "About Thermostat"))
self.actionAbout_Thermostat.setToolTip(_translate("MainWindow", "Show Thermostat hardware revision, and settings related to i"))
self.actionLoad_all_configs.setText(_translate("MainWindow", "Load all channel configs from flash"))
self.actionLoad_all_configs.setToolTip(_translate("MainWindow", "Restore configuration for all channels from flash"))
self.actionSave_all_configs.setText(_translate("MainWindow", "Save all channel configs to flash"))
self.actionSave_all_configs.setToolTip(_translate("MainWindow", "Save configuration for all channels to flash"))
from pglive.sources.live_plot_widget import LivePlotWidget
from pyqtgraph.parametertree import ParameterTree
from waitingspinnerwidget import QtWaitingSpinner
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
MainWindow = QtWidgets.QMainWindow()
ui = Ui_MainWindow()
ui.setupUi(MainWindow)
MainWindow.show()
sys.exit(app.exec())

View File

@ -1,194 +0,0 @@
"""
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()

View File

@ -1,4 +1,5 @@
from pytec.client import Client import time
from pythermostat.client import Client
tec = Client() #(host="localhost", port=6667) tec = Client() #(host="localhost", port=6667)
tec.set_param("b-p", 1, "t0", 20) tec.set_param("b-p", 1, "t0", 20)
@ -7,5 +8,6 @@ print(tec.get_pid())
print(tec.get_output()) print(tec.get_output())
print(tec.get_postfilter()) print(tec.get_postfilter())
print(tec.get_b_parameter()) print(tec.get_b_parameter())
for data in tec.report_mode(): while True:
print(data) print(tec.get_report())
time.sleep(0.05)

View File

@ -3,16 +3,16 @@ requires = ["setuptools"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "pytec" name = "pythermostat"
version = "0.0" version = "0.0"
authors = [{name = "M-Labs"}] authors = [{name = "M-Labs"}]
description = "Control TEC" description = "Python utilities for the Sinara 8451 Thermostat"
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat" urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
license = {text = "GPLv3"} license = {text = "GPLv3"}
[project.gui-scripts] [project.gui-scripts]
tec_qt = "tec_qt:main" thermostat_plot = "pythermostat.plot:main"
[tool.setuptools] [project.scripts]
packages.find = {} thermostat_autotune = "pythermostat.autotune:main"
py-modules = ["aioexample", "autotune", "example", "plot", "tec_qt", "ui_tec_qt", "waitingspinnerwidget"] thermostat_test = "pythermostat.test:main"

View File

@ -1,9 +1,10 @@
import math import math
import logging import logging
import time
from collections import deque, namedtuple from collections import deque, namedtuple
from enum import Enum from enum import Enum
from pytec.client import Client from pythermostat.client import Client
# Based on hirshmann pid-autotune libiary # Based on hirshmann pid-autotune libiary
# See https://github.com/hirschmann/pid-autotune # See https://github.com/hirschmann/pid-autotune
@ -17,7 +18,6 @@ class PIDAutotuneState(Enum):
STATE_RELAY_STEP_DOWN = 'relay step down' STATE_RELAY_STEP_DOWN = 'relay step down'
STATE_SUCCEEDED = 'succeeded' STATE_SUCCEEDED = 'succeeded'
STATE_FAILED = 'failed' STATE_FAILED = 'failed'
STATE_READY = 'ready'
class PIDAutotune: class PIDAutotune:
@ -57,20 +57,6 @@ class PIDAutotune:
self._Ku = 0 self._Ku = 0
self._Pu = 0 self._Pu = 0
def setParam(self, target, step, noiseband, sampletime, lookback):
self._setpoint = target
self._outputstep = step
self._out_max = step
self._out_min = -step
self._noiseband = noiseband
self._inputs = deque(maxlen=round(lookback / sampletime))
def setReady(self):
self._state = PIDAutotuneState.STATE_READY
def setOff(self):
self._state = PIDAutotuneState.STATE_OFF
def state(self): def state(self):
"""Get the current state.""" """Get the current state."""
return self._state return self._state
@ -96,13 +82,6 @@ class PIDAutotune:
kd = divisors[2] * self._Ku * self._Pu kd = divisors[2] * self._Ku * self._Pu
return PIDAutotune.PIDParams(kp, ki, kd) return PIDAutotune.PIDParams(kp, ki, kd)
def get_tec_pid (self):
divisors = self._tuning_rules["tyreus-luyben"]
kp = self._Ku * divisors[0]
ki = divisors[1] * self._Ku / self._Pu
kd = divisors[2] * self._Ku * self._Pu
return kp, ki, kd
def run(self, input_val, time_input): def run(self, input_val, time_input):
"""To autotune a system, this method must be called periodically. """To autotune a system, this method must be called periodically.
@ -117,8 +96,7 @@ class PIDAutotune:
if (self._state == PIDAutotuneState.STATE_OFF if (self._state == PIDAutotuneState.STATE_OFF
or self._state == PIDAutotuneState.STATE_SUCCEEDED or self._state == PIDAutotuneState.STATE_SUCCEEDED
or self._state == PIDAutotuneState.STATE_FAILED or self._state == PIDAutotuneState.STATE_FAILED):
or self._state == PIDAutotuneState.STATE_READY):
self._state = PIDAutotuneState.STATE_RELAY_STEP_UP self._state = PIDAutotuneState.STATE_RELAY_STEP_UP
self._last_run_timestamp = now self._last_run_timestamp = now
@ -222,20 +200,20 @@ class PIDAutotune:
# calculate ultimate gain # calculate ultimate gain
self._Ku = 4.0 * self._outputstep / \ self._Ku = 4.0 * self._outputstep / \
(self._induced_amplitude * math.pi) (self._induced_amplitude * math.pi)
logging.debug('Ku: {0}'.format(self._Ku)) print('Ku: {0}'.format(self._Ku))
# calculate ultimate period in seconds # calculate ultimate period in seconds
period1 = self._peak_timestamps[3] - self._peak_timestamps[1] period1 = self._peak_timestamps[3] - self._peak_timestamps[1]
period2 = self._peak_timestamps[4] - self._peak_timestamps[2] period2 = self._peak_timestamps[4] - self._peak_timestamps[2]
self._Pu = 0.5 * (period1 + period2) / 1000.0 self._Pu = 0.5 * (period1 + period2) / 1000.0
logging.debug('Pu: {0}'.format(self._Pu)) print('Pu: {0}'.format(self._Pu))
for rule in self._tuning_rules: for rule in self._tuning_rules:
params = self.get_pid_parameters(rule) params = self.get_pid_parameters(rule)
logging.debug('rule: {0}'.format(rule)) print('rule: {0}'.format(rule))
logging.debug('Kp: {0}'.format(params.Kp)) print('Kp: {0}'.format(params.Kp))
logging.debug('Ki: {0}'.format(params.Ki)) print('Ki: {0}'.format(params.Ki))
logging.debug('Kd: {0}'.format(params.Kd)) print('Kd: {0}'.format(params.Kd))
return True return True
return False return False
@ -259,13 +237,14 @@ def main():
tec = Client() tec = Client()
data = next(tec.report_mode()) data = tec.get_report()
ch = data[channel] ch = data[channel]
tuner = PIDAutotune(target_temperature, output_step, tuner = PIDAutotune(target_temperature, output_step,
lookback, noiseband, ch['interval']) lookback, noiseband, ch['interval'])
for data in tec.report_mode(): while True:
data = tec.get_report()
ch = data[channel] ch = data[channel]
@ -278,6 +257,8 @@ def main():
tec.set_param("output", channel, "i_set", tuner_out) tec.set_param("output", channel, "i_set", tuner_out)
time.sleep(0.05)
tec.set_param("output", channel, "i_set", 0) tec.set_param("output", channel, "i_set", 0)

View File

@ -1,7 +1,7 @@
import socket import socket
import json import json
import logging import logging
import time
class CommandError(Exception): class CommandError(Exception):
pass pass
@ -41,7 +41,6 @@ class Client:
line = self._read_line() line = self._read_line()
response = json.loads(line) response = json.loads(line)
logging.debug(f"{command}: {response}")
if "error" in response: if "error" in response:
raise CommandError(response["error"]) raise CommandError(response["error"])
return response return response
@ -115,18 +114,18 @@ class Client:
""" """
return self._get_conf("postfilter") return self._get_conf("postfilter")
def report_mode(self): def get_report(self):
"""Start reporting measurement values """Obtain one-time report on measurement values
Example of yielded data:: Example of yielded data::
{'channel': 0, {'channel': 0,
'time': 2302524, 'time': 2302524,
'interval': 0.12
'adc': 0.6199188965423515, 'adc': 0.6199188965423515,
'sens': 6138.519310282602, 'sens': 6138.519310282602,
'temperature': 36.87032392655527, 'temperature': 36.87032392655527,
'pid_engaged': True, 'pid_engaged': True,
'i_set': 2.0635816680889123, 'i_set': 2.0635816680889123,
'vref': 1.494,
'dac_value': 2.527790834044456, 'dac_value': 2.527790834044456,
'dac_feedback': 2.523, 'dac_feedback': 2.523,
'i_tec': 2.331, 'i_tec': 2.331,
@ -134,16 +133,19 @@ class Client:
'tec_u_meas': 2.5340000000000003, 'tec_u_meas': 2.5340000000000003,
'pid_output': 2.067581958092247} 'pid_output': 2.067581958092247}
""" """
while True: return self._get_conf("report")
self._socket.sendall("report\n".encode('utf-8'))
line = self._read_line() def get_ipv4(self):
if not line: """Get the IPv4 settings of the Thermostat"""
break return self._command("ipv4")
try:
yield json.loads(line) def get_fan(self):
except json.decoder.JSONDecodeError: """Get Thermostat current fan settings"""
pass return self._command("fan")
time.sleep(0.05)
def get_hwrev(self):
"""Get Thermostat hardware revision"""
return self._command("hwrev")
def set_param(self, topic, channel, field="", value=""): def set_param(self, topic, channel, field="", value=""):
"""Set configuration parameters """Set configuration parameters
@ -168,18 +170,38 @@ class Client:
self.set_param("pid", channel, "target", value=target) self.set_param("pid", channel, "target", value=target)
self.set_param("output", channel, "pid") self.set_param("output", channel, "pid")
def save_config(self): def save_config(self, channel=""):
"""Save current configuration to EEPROM""" """Save current configuration to EEPROM"""
self._command("save") self._command("save", channel)
if channel != "":
self._read_line() # read the extra {}
def load_config(self): def load_config(self, channel=""):
"""Load current configuration from EEPROM""" """Load current configuration from EEPROM"""
self._command("load") self._command("load", channel)
if channel != "":
self._read_line() # read the extra {}
def hw_rev(self): def reset(self):
"""Get Thermostat hardware revision""" """Reset the device"""
return self._command("hwrev") self._socket.sendall("reset".encode("utf-8"))
self.disconnect() # resetting ends the TCP session, disconnect anyway
def fan(self): def enter_dfu_mode(self):
"""Get Thermostat current fan settings""" """Reset device and enters USB device firmware update (DFU) mode"""
return self._command("fan") self._socket.sendall("dfu".encode("utf-8"))
self.disconnect() # resetting ends the TCP session, disconnect anyway
def set_ipv4(self, address, netmask, gateway=""):
"""Configure IPv4 address, netmask length, and optional default gateway"""
self._command("ipv4", f"{address}/{netmask}", gateway)
def set_fan(self, power=None):
"""Set fan power with values from 1 to 100. If omitted, set according to fcurve"""
if power is None:
power = "auto"
self._command("fan", power)
def set_fcurve(self, a=1.0, b=0.0, c=0.0):
"""Set fan controller curve coefficients"""
self._command("fcurve", a, b, c)

View File

@ -0,0 +1,137 @@
import time
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from threading import Thread, Lock
from pythermostat.client import Client
def main():
TIME_WINDOW = 300.0
tec = Client()
target_temperature = tec.get_pid()[0]['target']
print("Channel 0 target temperature: {:.3f}".format(target_temperature))
class Series:
def __init__(self, conv=lambda x: x):
self.conv = conv
self.x_data = []
self.y_data = []
def append(self, x, y):
self.x_data.append(x)
self.y_data.append(self.conv(y))
def clip(self, min_x):
drop = 0
while drop < len(self.x_data) and self.x_data[drop] < min_x:
drop += 1
self.x_data = self.x_data[drop:]
self.y_data = self.y_data[drop:]
series = {
# 'adc': Series(),
# 'sens': Series(lambda x: x * 0.0001),
'temperature': Series(),
# 'i_set': Series(),
'pid_output': Series(),
# 'vref': Series(),
# 'dac_value': Series(),
# 'dac_feedback': Series(),
# 'i_tec': Series(),
'tec_i': Series(),
'tec_u_meas': Series(),
# 'interval': Series(),
}
series_lock = Lock()
quit = False
def recv_data(tec):
global last_packet_time
while True:
data = tec.get_report()
ch0 = data[0]
series_lock.acquire()
try:
for k, s in series.items():
if k in ch0:
v = ch0[k]
if type(v) is float:
s.append(ch0['time'], v)
finally:
series_lock.release()
if quit:
break
time.sleep(0.05)
thread = Thread(target=recv_data, args=(tec,))
thread.start()
fig, ax = plt.subplots()
for k, s in series.items():
s.plot, = ax.plot([], [], label=k)
legend = ax.legend()
def animate(i):
min_x, max_x, min_y, max_y = None, None, None, None
series_lock.acquire()
try:
for k, s in series.items():
s.plot.set_data(s.x_data, s.y_data)
if len(s.y_data) > 0:
s.plot.set_label("{}: {:.3f}".format(k, s.y_data[-1]))
if len(s.x_data) > 0:
min_x_ = min(s.x_data)
if min_x is None:
min_x = min_x_
else:
min_x = min(min_x, min_x_)
max_x_ = max(s.x_data)
if max_x is None:
max_x = max_x_
else:
max_x = max(max_x, max_x_)
if len(s.y_data) > 0:
min_y_ = min(s.y_data)
if min_y is None:
min_y = min_y_
else:
min_y = min(min_y, min_y_)
max_y_ = max(s.y_data)
if max_y is None:
max_y = max_y_
else:
max_y = max(max_y, max_y_)
if min_x and max_x - TIME_WINDOW > min_x:
for s in series.values():
s.clip(max_x - TIME_WINDOW)
finally:
series_lock.release()
if min_x != max_x:
ax.set_xlim(min_x, max_x)
if min_y != max_y:
margin_y = 0.01 * (max_y - min_y)
ax.set_ylim(min_y - margin_y, max_y + margin_y)
nonlocal legend
legend.remove()
legend = ax.legend()
ani = animation.FuncAnimation(
fig, animate, interval=1, blit=False, save_count=50)
plt.show()
quit = True
thread.join()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,81 @@
import argparse
from contextlib import contextmanager
from pythermostat.client import Client
CHANNELS = 2
def get_argparser():
parser = argparse.ArgumentParser(description="Thermostat hardware testing script")
parser.add_argument("host", metavar="HOST", default="192.168.1.26", nargs="?")
parser.add_argument("port", metavar="PORT", default=23, nargs="?")
parser.add_argument(
"-r",
"--testing_resistance",
default=10_000,
help="Testing resistance value through SENS pin in Ohms",
)
parser.add_argument(
"-d",
"--deviation",
default=1,
help="Allowed deviation of resistance in percentage",
)
return parser
def main():
args = get_argparser().parse_args()
min_allowed_resistance = args.testing_resistance * (1 - args.deviation / 100)
max_allowed_resistance = args.testing_resistance * (1 + args.deviation / 100)
print(min_allowed_resistance, max_allowed_resistance)
thermostat = Client(args.host, args.port)
for channel in range(CHANNELS):
print(f"Channel {channel} is active")
print("Checking resistance through SENS input ....", end=" ")
sens_resistance = thermostat.get_report()[channel]["sens"]
if sens_resistance is not None:
print(sens_resistance, "Ω")
if min_allowed_resistance <= sens_resistance <= max_allowed_resistance:
print("PASSED")
else:
print("FAILED")
else:
print("Floating SENS input! Is the channel connected?")
with preserve_thermostat_output_settings(thermostat, channel):
test_output_settings = {
"max_i_pos": 2,
"max_i_neg": 2,
"max_v": 4,
"i_set": 0.1,
"polarity": "normal",
}
for field, value in test_output_settings.items():
thermostat.set_param("output", channel, field, value)
input(f"Check if channel {channel} current = 0.1 A, and press ENTER...")
input(f"Channel {channel} testing done, press ENTER to continue.")
print()
print("Testing complete.")
@contextmanager
def preserve_thermostat_output_settings(client, channel):
original_output_settings = client.get_output()[channel]
yield original_output_settings
for setting in "max_i_pos", "max_i_neg", "max_v", "i_set", "polarity":
client.set_param("output", channel, setting, original_output_settings[setting])
if __name__ == "__main__":
main()