forked from M-Labs/thermostat
Compare commits
18 Commits
348f2600f3
...
1eba7d8556
Author | SHA1 | Date | |
---|---|---|---|
1eba7d8556 | |||
a0bc119ffb | |||
bfbe8b79a7 | |||
f14e67de82 | |||
0a99e29d4d | |||
675ce9f0c7 | |||
e7d9126967 | |||
8589ebdf0f | |||
6e9b1cfe21 | |||
ca4e43b0d9 | |||
72c1aab682 | |||
36d80ebdff | |||
09300b5d44 | |||
9743dca775 | |||
11131deda2 | |||
764774fbce | |||
4beeec6021 | |||
6b8a5f5bb8 |
18
README.md
18
README.md
@ -71,7 +71,7 @@ openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv
|
||||
|
||||
A GUI has been developed for easy configuration and plotting of key parameters.
|
||||
|
||||
The Python GUI program is located at pytec/tec_qt.py, and is developed based on the Python library pyqtgraph. The GUI can be configured and
|
||||
The Python GUI program is located at pythermostat/pythermostat/thermostat_qt.py, and is developed based on the Python library pyqtgraph. The GUI can be configured and
|
||||
launched automatically by running:
|
||||
|
||||
```
|
||||
@ -250,6 +250,22 @@ of channel 0 to the PID algorithm:
|
||||
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
|
||||
|
||||
| Name | Color | Meaning |
|
||||
|
@ -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
|
||||
|
||||
```shell
|
||||
python pytec/plot.py
|
||||
python pythermostat/pythermostat/plot.py
|
||||
```
|
||||
|
||||
![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
|
||||
|
||||
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
|
||||
|
||||
```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
|
||||
|
10
flake.nix
10
flake.nix
@ -58,11 +58,11 @@
|
||||
auditable = false;
|
||||
};
|
||||
|
||||
pytec = pkgs.python3Packages.buildPythonPackage {
|
||||
pname = "pytec";
|
||||
pythermostat = pkgs.python3Packages.buildPythonPackage {
|
||||
pname = "pythermostat";
|
||||
version = "0.0.0";
|
||||
format = "pyproject";
|
||||
src = "${self}/pytec";
|
||||
src = "${self}/pythermostat";
|
||||
|
||||
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
|
||||
propagatedBuildInputs =
|
||||
@ -99,13 +99,13 @@
|
||||
in
|
||||
{
|
||||
packages.x86_64-linux = {
|
||||
inherit thermostat pytec;
|
||||
inherit thermostat pythermostat;
|
||||
default = thermostat;
|
||||
};
|
||||
|
||||
apps.x86_64-linux.thermostat_gui = {
|
||||
type = "app";
|
||||
program = "${self.packages.x86_64-linux.pytec}/bin/tec_qt";
|
||||
program = "${self.packages.x86_64-linux.pythermostat}/bin/tec_qt";
|
||||
};
|
||||
|
||||
hydraJobs = {
|
||||
|
@ -1,4 +0,0 @@
|
||||
graft examples
|
||||
include pytec/gui/resources/artiq.ico
|
||||
include pytec/gui/view/param_tree.json
|
||||
include pytec/gui/view/tec_qt.ui
|
131
pytec/plot.py
131
pytec/plot.py
@ -1,131 +0,0 @@
|
||||
import time
|
||||
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
|
||||
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)
|
||||
|
||||
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()
|
@ -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=['autotune', 'plot', 'tec_qt'],
|
||||
)
|
4
pythermostat/MANIFEST.in
Normal file
4
pythermostat/MANIFEST.in
Normal file
@ -0,0 +1,4 @@
|
||||
graft examples
|
||||
include pythermostat/gui/resources/artiq.ico
|
||||
include pythermostat/gui/view/param_tree.json
|
||||
include pythermostat/gui/view/tec_qt.ui
|
@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from pytec.aioclient import AsyncioClient
|
||||
from pythermostat.aioclient import AsyncioClient
|
||||
|
||||
|
||||
async def poll_for_info(tec):
|
@ -1,5 +1,5 @@
|
||||
import time
|
||||
from pytec.client import Client
|
||||
from pythermostat.client import Client
|
||||
|
||||
tec = Client() #(host="localhost", port=6667)
|
||||
tec.set_param("b-p", 1, "t0", 20)
|
@ -3,16 +3,17 @@ requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pytec"
|
||||
name = "pythermostat"
|
||||
version = "0.0"
|
||||
authors = [{name = "M-Labs"}]
|
||||
description = "Control TEC"
|
||||
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
|
||||
license = {text = "GPLv3"}
|
||||
|
||||
[project.gui-scripts]
|
||||
tec_qt = "tec_qt:main"
|
||||
[project.scripts]
|
||||
thermostat_autotune = "pythermostat.autotune:main"
|
||||
thermostat_test = "pythermostat.test:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages.find = {}
|
||||
py-modules = ["autotune", "plot", "tec_qt"]
|
||||
[project.gui-scripts]
|
||||
thermostat_plot = "pythermostat.plot:main"
|
||||
thermostat_qt = "pythermostat.thermostat_qt:main"
|
@ -1,9 +1,10 @@
|
||||
import math
|
||||
import logging
|
||||
import time
|
||||
from collections import deque, namedtuple
|
||||
from enum import Enum
|
||||
|
||||
from pytec.client import Client
|
||||
from pythermostat.client import Client
|
||||
|
||||
# Based on hirshmann pid-autotune libiary
|
||||
# See https://github.com/hirschmann/pid-autotune
|
||||
@ -259,13 +260,14 @@ def main():
|
||||
|
||||
tec = Client()
|
||||
|
||||
data = next(tec.report_mode())
|
||||
data = tec.get_report()
|
||||
ch = data[channel]
|
||||
|
||||
tuner = PIDAutotune(target_temperature, output_step,
|
||||
lookback, noiseband, ch['interval'])
|
||||
|
||||
for data in tec.report_mode():
|
||||
while True:
|
||||
data = tec.get_report()
|
||||
|
||||
ch = data[channel]
|
||||
|
||||
@ -278,6 +280,8 @@ def main():
|
||||
|
||||
tec.set_param("output", channel, "i_set", tuner_out)
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
tec.set_param("output", channel, "i_set", 0)
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
|
||||
from qasync import asyncSlot
|
||||
from pytec.gui.model.property import Property, PropertyMeta
|
||||
import asyncio
|
||||
import logging
|
||||
from enum import Enum
|
||||
from pytec.aioclient import AsyncioClient
|
||||
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
|
||||
from qasync import asyncSlot
|
||||
from pythermostat.aioclient import AsyncioClient
|
||||
from pythermostat.gui.model.property import Property, PropertyMeta
|
||||
|
||||
|
||||
class ThermostatConnectionState(Enum):
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
@ -1,6 +1,6 @@
|
||||
from PyQt6 import QtWidgets, QtCore
|
||||
from PyQt6.QtCore import pyqtSlot
|
||||
from pytec.gui.model.thermostat import ThermostatConnectionState
|
||||
from pythermostat.gui.model.thermostat import ThermostatConnectionState
|
||||
|
||||
|
||||
class ConnectionDetailsMenu(QtWidgets.QMenu):
|
@ -5,7 +5,7 @@ from pglive.sources.live_plot import LiveLinePlot
|
||||
from pglive.sources.live_axis import LiveAxis
|
||||
from collections import deque
|
||||
import pyqtgraph as pg
|
||||
from pytec.gui.model.thermostat import ThermostatConnectionState
|
||||
from pythermostat.gui.model.thermostat import ThermostatConnectionState
|
||||
|
||||
pg.setConfigOptions(antialias=True)
|
||||
|
@ -563,7 +563,7 @@
|
||||
<customwidget>
|
||||
<class>QtWaitingSpinner</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>pytec.gui.view.waitingspinnerwidget</header>
|
||||
<header>pythermostat.gui.view.waitingspinnerwidget</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
</customwidgets>
|
@ -2,8 +2,8 @@ import logging
|
||||
from PyQt6 import QtWidgets, QtGui, QtCore
|
||||
from PyQt6.QtCore import pyqtSignal, pyqtSlot, QSignalBlocker
|
||||
from qasync import asyncSlot
|
||||
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
|
||||
from pytec.gui.model.thermostat import ThermostatConnectionState
|
||||
from pythermostat.gui.view.net_settings_input_diag import NetSettingsInputDiag
|
||||
from pythermostat.gui.model.thermostat import ThermostatConnectionState
|
||||
|
||||
|
||||
class ThermostatSettingsMenu(QtWidgets.QMenu):
|
137
pythermostat/pythermostat/plot.py
Normal file
137
pythermostat/pythermostat/plot.py
Normal 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()
|
@ -1,6 +1,6 @@
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
from pytec.client import Client
|
||||
from pythermostat.client import Client
|
||||
|
||||
|
||||
CHANNELS = 2
|
@ -9,16 +9,16 @@ from PyQt6 import QtWidgets, QtGui, uic
|
||||
from PyQt6.QtCore import pyqtSlot
|
||||
import qasync
|
||||
from qasync import asyncSlot, asyncClose
|
||||
from autotune import PIDAutotuneState
|
||||
from pytec.gui.model.thermostat import Thermostat, ThermostatConnectionState
|
||||
from pytec.gui.model.pid_autotuner import PIDAutoTuner
|
||||
from pytec.gui.view.zero_limits_warning_view import ZeroLimitsWarningView
|
||||
from pytec.gui.view.thermostat_settings_menu import ThermostatSettingsMenu
|
||||
from pytec.gui.view.connection_details_menu import ConnectionDetailsMenu
|
||||
from pytec.gui.view.plot_options_menu import PlotOptionsMenu
|
||||
from pytec.gui.view.live_plot_view import LiveDataPlotter
|
||||
from pytec.gui.view.ctrl_panel import CtrlPanel
|
||||
from pytec.gui.view.info_box import InfoBox
|
||||
from pythermostat.autotune import PIDAutotuneState
|
||||
from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState
|
||||
from pythermostat.gui.model.pid_autotuner import PIDAutoTuner
|
||||
from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu
|
||||
from pythermostat.gui.view.ctrl_panel import CtrlPanel
|
||||
from pythermostat.gui.view.info_box import InfoBox
|
||||
from pythermostat.gui.view.live_plot_view import LiveDataPlotter
|
||||
from pythermostat.gui.view.plot_options_menu import PlotOptionsMenu
|
||||
from pythermostat.gui.view.thermostat_settings_menu import ThermostatSettingsMenu
|
||||
from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView
|
||||
|
||||
|
||||
def get_argparser():
|
||||
@ -42,7 +42,7 @@ def get_argparser():
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--param_tree",
|
||||
default=importlib.resources.files("pytec.gui.view").joinpath("param_tree.json"),
|
||||
default=importlib.resources.files("pythermostat.gui.view").joinpath("param_tree.json"),
|
||||
help="Param Tree Description JSON File",
|
||||
)
|
||||
|
||||
@ -55,7 +55,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
def __init__(self, args):
|
||||
super().__init__()
|
||||
|
||||
ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui")
|
||||
ui_file_path = importlib.resources.files("pythermostat.gui.view").joinpath("tec_qt.ui")
|
||||
uic.loadUi(ui_file_path, self)
|
||||
|
||||
self._info_box = InfoBox()
|
||||
@ -229,7 +229,7 @@ async def coro_main():
|
||||
app.aboutToQuit.connect(app_quit_event.set)
|
||||
app.setWindowIcon(
|
||||
QtGui.QIcon(
|
||||
str(importlib.resources.files("pytec.gui.resources").joinpath("artiq.ico"))
|
||||
str(importlib.resources.files("pythermostat.gui.resources").joinpath("artiq.ico"))
|
||||
)
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user