forked from M-Labs/thermostat
Compare commits
No commits in common. "gui_dev" and "master" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,2 @@
|
|||||||
target/
|
target/
|
||||||
result
|
result
|
||||||
*.pyc
|
|
16
README.md
16
README.md
@ -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
|
||||||
|
|
||||||
@ -283,7 +271,7 @@ with the following keys.
|
|||||||
| `tec_u_meas` | Volts | Measurement of the voltage across the TEC |
|
| `tec_u_meas` | Volts | Measurement of the voltage across the TEC |
|
||||||
| `pid_output` | Amperes | PID control output |
|
| `pid_output` | Amperes | PID control output |
|
||||||
|
|
||||||
Note: With Thermostat v2 and below, the voltage and current readouts `i_tec` and `tec_i` are noisy without the hardware fix shown in [this PR][https://git.m-labs.hk/M-Labs/thermostat/pulls/105].
|
Note: With Thermostat v2 and below, the voltage and current readouts `i_tec` and `tec_i` are disabled and null due to faulty hardware that introduces a lot of noise in the signal.
|
||||||
|
|
||||||
## PID Tuning
|
## PID Tuning
|
||||||
|
|
||||||
|
59
flake.nix
59
flake.nix
@ -55,64 +55,9 @@
|
|||||||
|
|
||||||
dontFixup = true;
|
dontFixup = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
qasync = pkgs.python3Packages.buildPythonPackage rec {
|
|
||||||
pname = "qasync";
|
|
||||||
version = "0.27.1";
|
|
||||||
format = "pyproject";
|
|
||||||
src = pkgs.fetchPypi {
|
|
||||||
inherit pname version;
|
|
||||||
hash = "sha256-jcdo/R7l3hBEx8MF7M8tOdJNh4A+pxGJ1AJPtHX0mF8=";
|
|
||||||
};
|
|
||||||
buildInputs = [ pkgs.python3Packages.poetry-core ];
|
|
||||||
propagatedBuildInputs = [ pkgs.python3Packages.pyqt6 ];
|
|
||||||
};
|
|
||||||
|
|
||||||
pyqtgraph = pkgs.python3Packages.buildPythonPackage rec {
|
|
||||||
pname = "pyqtgraph";
|
|
||||||
version = "0.13.3";
|
|
||||||
format = "pyproject";
|
|
||||||
src = pkgs.fetchPypi {
|
|
||||||
inherit pname version;
|
|
||||||
hash = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4=";
|
|
||||||
};
|
|
||||||
propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ];
|
|
||||||
};
|
|
||||||
|
|
||||||
pglive = pkgs.python3Packages.buildPythonPackage rec {
|
|
||||||
pname = "pglive";
|
|
||||||
version = "0.7.2";
|
|
||||||
format = "pyproject";
|
|
||||||
src = pkgs.fetchPypi {
|
|
||||||
inherit pname version;
|
|
||||||
hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A=";
|
|
||||||
};
|
|
||||||
buildInputs = [ pkgs.python3Packages.poetry-core ];
|
|
||||||
propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ];
|
|
||||||
};
|
|
||||||
|
|
||||||
thermostat_gui = pkgs.python3Packages.buildPythonPackage {
|
|
||||||
pname = "thermostat_gui";
|
|
||||||
version = "0.0.0";
|
|
||||||
format = "pyproject";
|
|
||||||
src = "${self}/pytec";
|
|
||||||
|
|
||||||
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
|
|
||||||
propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync pglive ]);
|
|
||||||
|
|
||||||
dontWrapQtApps = true;
|
|
||||||
postFixup = ''
|
|
||||||
wrapQtApp "$out/bin/tec_qt"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
in {
|
in {
|
||||||
packages.x86_64-linux = {
|
packages.x86_64-linux = {
|
||||||
inherit thermostat thermostat_gui;
|
inherit thermostat;
|
||||||
};
|
|
||||||
|
|
||||||
apps.x86_64-linux.thermostat_gui = {
|
|
||||||
type = "app";
|
|
||||||
program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
hydraJobs = {
|
hydraJobs = {
|
||||||
@ -124,7 +69,7 @@
|
|||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
rust openocd dfu-util
|
rust openocd dfu-util
|
||||||
] ++ (with python3Packages; [
|
] ++ (with python3Packages; [
|
||||||
numpy matplotlib pyqtgraph setuptools pyqt6 qasync pglive
|
numpy matplotlib
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
defaultPackage.x86_64-linux = thermostat;
|
defaultPackage.x86_64-linux = thermostat;
|
||||||
|
@ -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
|
|
@ -17,7 +17,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,21 +56,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
|
|
||||||
self._peak_count = 0
|
|
||||||
|
|
||||||
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
|
||||||
@ -97,13 +81,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.
|
||||||
|
|
||||||
@ -118,8 +95,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
|
||||||
@ -223,20 +199,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
|
||||||
|
@ -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_pwm())
|
|
||||||
print(await tec.get_pid())
|
|
||||||
print(await tec.get_pwm())
|
|
||||||
print(await tec.get_postfilter())
|
|
||||||
print(await tec.get_steinhart_hart())
|
|
||||||
async for data in tec.report_mode():
|
|
||||||
print(data)
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
@ -1,18 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "pytec"
|
|
||||||
version = "0.0"
|
|
||||||
authors = [{name = "M-Labs"}]
|
|
||||||
description = "Control TEC"
|
|
||||||
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
|
|
||||||
license = {text = "GPLv3"}
|
|
||||||
|
|
||||||
[project.gui-scripts]
|
|
||||||
tec_qt = "tec_qt:main"
|
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
packages.find = {}
|
|
||||||
py-modules = ["autotune", "plot", "tec_qt"]
|
|
@ -1,279 +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):
|
|
||||||
pwm_report = await self.get_pwm()
|
|
||||||
for pwm_channel in pwm_report:
|
|
||||||
for limit in ["max_i_neg", "max_i_pos", "max_v"]:
|
|
||||||
if pwm_channel[limit]["value"] == 0.0:
|
|
||||||
logging.warning("`{}` limit is set to zero on channel {}".format(limit, pwm_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_pwm(self):
|
|
||||||
"""Retrieve PWM limits for the TEC
|
|
||||||
|
|
||||||
Example::
|
|
||||||
[{'channel': 0,
|
|
||||||
'center': 'vref',
|
|
||||||
'i_set': {'max': 2.9802790335151985, 'value': -0.02002179650216762},
|
|
||||||
'max_i_neg': {'max': 3.0, 'value': 3.0},
|
|
||||||
'max_v': {'max': 5.988, 'value': 5.988},
|
|
||||||
'max_i_pos': {'max': 3.0, 'value': 3.0}},
|
|
||||||
{'channel': 1,
|
|
||||||
'center': 'vref',
|
|
||||||
'i_set': {'max': 2.9802790335151985, 'value': -0.02002179650216762},
|
|
||||||
'max_i_neg': {'max': 3.0, 'value': 3.0},
|
|
||||||
'max_v': {'max': 5.988, 'value': 5.988},
|
|
||||||
'max_i_pos': {'max': 3.0, 'value': 3.0}}
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
return await self._get_conf("pwm")
|
|
||||||
|
|
||||||
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_steinhart_hart(self):
|
|
||||||
"""Retrieve Steinhart-Hart 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("s-h")
|
|
||||||
|
|
||||||
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("pwm", 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("pwm", 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')
|
|
@ -11,10 +11,6 @@ class Client:
|
|||||||
self._lines = [""]
|
self._lines = [""]
|
||||||
self._check_zero_limits()
|
self._check_zero_limits()
|
||||||
|
|
||||||
def disconnect(self):
|
|
||||||
self._socket.shutdown(socket.SHUT_RDWR)
|
|
||||||
self._socket.close()
|
|
||||||
|
|
||||||
def _check_zero_limits(self):
|
def _check_zero_limits(self):
|
||||||
pwm_report = self.get_pwm()
|
pwm_report = self.get_pwm()
|
||||||
for pwm_channel in pwm_report:
|
for pwm_channel in pwm_report:
|
||||||
@ -40,7 +36,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
|
||||||
@ -172,11 +167,3 @@ class Client:
|
|||||||
def load_config(self):
|
def load_config(self):
|
||||||
"""Load current configuration from EEPROM"""
|
"""Load current configuration from EEPROM"""
|
||||||
self._command("load")
|
self._command("load")
|
||||||
|
|
||||||
def hw_rev(self):
|
|
||||||
"""Get Thermostat hardware revision"""
|
|
||||||
return self._command("hwrev")
|
|
||||||
|
|
||||||
def fan(self):
|
|
||||||
"""Get Thermostat current fan settings"""
|
|
||||||
return self._command("fan")
|
|
||||||
|
@ -1,72 +0,0 @@
|
|||||||
from PyQt6.QtCore import QObject, pyqtSlot
|
|
||||||
from qasync import asyncSlot
|
|
||||||
from autotune import PIDAutotuneState, PIDAutotune
|
|
||||||
|
|
||||||
|
|
||||||
class PIDAutoTuner(QObject):
|
|
||||||
def __init__(self, parent, client, num_of_channel):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self._client = client
|
|
||||||
self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
|
|
||||||
self.target_temp = [20.0 for _ in range(num_of_channel)]
|
|
||||||
self.test_current = [1.0 for _ in range(num_of_channel)]
|
|
||||||
self.temp_swing = [1.5 for _ in range(num_of_channel)]
|
|
||||||
self.lookback = [3.0 for _ in range(num_of_channel)]
|
|
||||||
self.sampling_interval = [1 / 16.67 for _ in range(num_of_channel)]
|
|
||||||
|
|
||||||
@pyqtSlot(list)
|
|
||||||
def update_sampling_interval(self, interval):
|
|
||||||
self.sampling_interval = interval
|
|
||||||
|
|
||||||
def set_params(self, params_name, ch, val):
|
|
||||||
getattr(self, params_name)[ch] = val
|
|
||||||
|
|
||||||
def get_state(self, ch):
|
|
||||||
return self.autotuners[ch].state()
|
|
||||||
|
|
||||||
def load_params_and_set_ready(self, ch):
|
|
||||||
self.autotuners[ch].setParam(
|
|
||||||
self.target_temp[ch],
|
|
||||||
self.test_current[ch] / 1000,
|
|
||||||
self.temp_swing[ch],
|
|
||||||
1 / self.sampling_interval[ch],
|
|
||||||
self.lookback[ch],
|
|
||||||
)
|
|
||||||
self.autotuners[ch].setReady()
|
|
||||||
|
|
||||||
async def stop_pid_from_running(self, ch):
|
|
||||||
self.autotuners[ch].setOff()
|
|
||||||
await self._client.set_param("pwm", ch, "i_set", 0)
|
|
||||||
|
|
||||||
@asyncSlot(list)
|
|
||||||
async def tick(self, report):
|
|
||||||
for channel_report in report:
|
|
||||||
# TODO: Skip when PID Autotune or emit error message if NTC is not connected
|
|
||||||
if channel_report["temperature"] is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ch = channel_report["channel"]
|
|
||||||
match self.autotuners[ch].state():
|
|
||||||
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
|
|
||||||
self.autotuners[ch].run(
|
|
||||||
channel_report["temperature"], channel_report["time"]
|
|
||||||
)
|
|
||||||
await self._client.set_param(
|
|
||||||
"pwm", ch, "i_set", self.autotuners[ch].output()
|
|
||||||
)
|
|
||||||
case PIDAutotuneState.STATE_SUCCEEDED:
|
|
||||||
kp, ki, kd = self.autotuners[ch].get_tec_pid()
|
|
||||||
self.autotuners[ch].setOff()
|
|
||||||
|
|
||||||
await self._client.set_param("pid", ch, "kp", kp)
|
|
||||||
await self._client.set_param("pid", ch, "ki", ki)
|
|
||||||
await self._client.set_param("pid", ch, "kd", kd)
|
|
||||||
await self._client.set_param("pwm", ch, "pid")
|
|
||||||
|
|
||||||
await self._client.set_param(
|
|
||||||
"pid", ch, "target", self.target_temp[ch]
|
|
||||||
)
|
|
||||||
case PIDAutotuneState.STATE_FAILED:
|
|
||||||
self.autotuners[ch].setOff()
|
|
||||||
await self._client.set_param("pwm", ch, "i_set", 0)
|
|
@ -1,126 +0,0 @@
|
|||||||
# A Custom Class that allows defining a QObject Property Dynamically
|
|
||||||
# Adapted from: https://stackoverflow.com/questions/48425316/how-to-create-pyqt-properties-dynamically
|
|
||||||
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal
|
|
||||||
|
|
||||||
|
|
||||||
class PropertyMeta(type(QObject)):
|
|
||||||
"""Lets a class succinctly define Qt properties."""
|
|
||||||
|
|
||||||
def __new__(cls, name, bases, attrs):
|
|
||||||
for key in list(attrs.keys()):
|
|
||||||
attr = attrs[key]
|
|
||||||
if not isinstance(attr, Property):
|
|
||||||
continue
|
|
||||||
|
|
||||||
types = {list: "QVariantList", dict: "QVariantMap"}
|
|
||||||
type_ = types.get(attr.type_, attr.type_)
|
|
||||||
|
|
||||||
notifier = pyqtSignal(type_)
|
|
||||||
attrs[f"{key}_update"] = notifier
|
|
||||||
attrs[key] = PropertyImpl(type_=type_, name=key, notify=notifier)
|
|
||||||
|
|
||||||
return super().__new__(cls, name, bases, attrs)
|
|
||||||
|
|
||||||
|
|
||||||
class Property:
|
|
||||||
"""Property definition.
|
|
||||||
|
|
||||||
Instances of this class will be replaced with their full
|
|
||||||
implementation by the PropertyMeta metaclass.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, type_):
|
|
||||||
self.type_ = type_
|
|
||||||
|
|
||||||
|
|
||||||
class PropertyImpl(pyqtProperty):
|
|
||||||
"""Property implementation: gets, sets, and notifies of change."""
|
|
||||||
|
|
||||||
def __init__(self, type_, name, notify):
|
|
||||||
super().__init__(type_, self.getter, self.setter, notify=notify)
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
def getter(self, instance):
|
|
||||||
return getattr(instance, f"_{self.name}")
|
|
||||||
|
|
||||||
def setter(self, instance, value):
|
|
||||||
signal = getattr(instance, f"{self.name}_update")
|
|
||||||
|
|
||||||
if type(value) in {list, dict}:
|
|
||||||
value = make_notified(value, signal)
|
|
||||||
|
|
||||||
setattr(instance, f"_{self.name}", value)
|
|
||||||
signal.emit(value)
|
|
||||||
|
|
||||||
|
|
||||||
class MakeNotified:
|
|
||||||
"""Adds notifying signals to lists and dictionaries.
|
|
||||||
|
|
||||||
Creates the modified classes just once, on initialization.
|
|
||||||
"""
|
|
||||||
|
|
||||||
change_methods = {
|
|
||||||
list: [
|
|
||||||
"__delitem__",
|
|
||||||
"__iadd__",
|
|
||||||
"__imul__",
|
|
||||||
"__setitem__",
|
|
||||||
"append",
|
|
||||||
"extend",
|
|
||||||
"insert",
|
|
||||||
"pop",
|
|
||||||
"remove",
|
|
||||||
"reverse",
|
|
||||||
"sort",
|
|
||||||
],
|
|
||||||
dict: [
|
|
||||||
"__delitem__",
|
|
||||||
"__ior__",
|
|
||||||
"__setitem__",
|
|
||||||
"clear",
|
|
||||||
"pop",
|
|
||||||
"popitem",
|
|
||||||
"setdefault",
|
|
||||||
"update",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
if not hasattr(dict, "__ior__"):
|
|
||||||
# Dictionaries don't have | operator in Python < 3.9.
|
|
||||||
self.change_methods[dict].remove("__ior__")
|
|
||||||
self.notified_class = {
|
|
||||||
type_: self.make_notified_class(type_) for type_ in [list, dict]
|
|
||||||
}
|
|
||||||
|
|
||||||
def __call__(self, seq, signal):
|
|
||||||
"""Returns a notifying version of the supplied list or dict."""
|
|
||||||
notified_class = self.notified_class[type(seq)]
|
|
||||||
notified_seq = notified_class(seq)
|
|
||||||
notified_seq.signal = signal
|
|
||||||
return notified_seq
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def make_notified_class(cls, parent):
|
|
||||||
notified_class = type(f"notified_{parent.__name__}", (parent,), {})
|
|
||||||
for method_name in cls.change_methods[parent]:
|
|
||||||
original = getattr(notified_class, method_name)
|
|
||||||
notified_method = cls.make_notified_method(original, parent)
|
|
||||||
setattr(notified_class, method_name, notified_method)
|
|
||||||
return notified_class
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def make_notified_method(method, parent):
|
|
||||||
@wraps(method)
|
|
||||||
def notified_method(self, *args, **kwargs):
|
|
||||||
result = getattr(parent, method.__name__)(self, *args, **kwargs)
|
|
||||||
self.signal.emit(self)
|
|
||||||
return result
|
|
||||||
|
|
||||||
return notified_method
|
|
||||||
|
|
||||||
|
|
||||||
make_notified = MakeNotified()
|
|
@ -1,138 +0,0 @@
|
|||||||
from pytec.aioclient import Client
|
|
||||||
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
|
|
||||||
from qasync import asyncSlot
|
|
||||||
from pytec.gui.model.property import Property, PropertyMeta
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class WrappedClient(QObject, Client):
|
|
||||||
connection_error = pyqtSignal()
|
|
||||||
|
|
||||||
async def _read_line(self):
|
|
||||||
try:
|
|
||||||
return await super()._read_line()
|
|
||||||
except (Exception, TimeoutError, asyncio.exceptions.TimeoutError):
|
|
||||||
logging.error("Client connection error, disconnecting", exc_info=True)
|
|
||||||
self.connection_error.emit()
|
|
||||||
|
|
||||||
|
|
||||||
class Thermostat(QObject, metaclass=PropertyMeta):
|
|
||||||
hw_rev = Property(dict)
|
|
||||||
fan = Property(dict)
|
|
||||||
thermistor = Property(list)
|
|
||||||
pid = Property(list)
|
|
||||||
pwm = Property(list)
|
|
||||||
postfilter = Property(list)
|
|
||||||
interval = Property(list)
|
|
||||||
report = Property(list)
|
|
||||||
info_box_trigger = pyqtSignal(str, str)
|
|
||||||
|
|
||||||
def __init__(self, parent, client, update_s):
|
|
||||||
self._update_s = update_s
|
|
||||||
self._client = client
|
|
||||||
self._watch_task = None
|
|
||||||
self._report_mode_task = None
|
|
||||||
self._poll_for_report = True
|
|
||||||
super().__init__(parent)
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
self.task = asyncio.create_task(self.update_params())
|
|
||||||
while True:
|
|
||||||
if self.task.done():
|
|
||||||
if self.task.exception() is not None:
|
|
||||||
try:
|
|
||||||
raise self.task.exception()
|
|
||||||
except (
|
|
||||||
Exception,
|
|
||||||
TimeoutError,
|
|
||||||
asyncio.exceptions.TimeoutError,
|
|
||||||
):
|
|
||||||
logging.error(
|
|
||||||
"Encountered an error while updating parameter tree.",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
_ = self.task.result()
|
|
||||||
self.task = asyncio.create_task(self.update_params())
|
|
||||||
await asyncio.sleep(self._update_s)
|
|
||||||
|
|
||||||
async def get_hw_rev(self):
|
|
||||||
self.hw_rev = await self._client.hw_rev()
|
|
||||||
return self.hw_rev
|
|
||||||
|
|
||||||
async def update_params(self):
|
|
||||||
self.fan = await self._client.get_fan()
|
|
||||||
self.pwm = await self._client.get_pwm()
|
|
||||||
if self._poll_for_report:
|
|
||||||
self.report = await self._client.report()
|
|
||||||
self.interval = [
|
|
||||||
self.report[i]["interval"] for i in range(len(self.report))
|
|
||||||
]
|
|
||||||
self.pid = await self._client.get_pid()
|
|
||||||
self.thermistor = await self._client.get_steinhart_hart()
|
|
||||||
self.postfilter = await self._client.get_postfilter()
|
|
||||||
|
|
||||||
def connected(self):
|
|
||||||
return self._client.connected
|
|
||||||
|
|
||||||
def connecting(self):
|
|
||||||
return self._client.connecting
|
|
||||||
|
|
||||||
def start_watching(self):
|
|
||||||
self._watch_task = asyncio.create_task(self.run())
|
|
||||||
|
|
||||||
@asyncSlot()
|
|
||||||
async def stop_watching(self):
|
|
||||||
if self._watch_task is not None:
|
|
||||||
await self.set_report_mode(False)
|
|
||||||
self._watch_task.cancel()
|
|
||||||
self._watch_task = None
|
|
||||||
self.task.cancel()
|
|
||||||
self.task = None
|
|
||||||
|
|
||||||
async def set_report_mode(self, enabled: bool):
|
|
||||||
self._poll_for_report = not enabled
|
|
||||||
if enabled:
|
|
||||||
self._report_mode_task = asyncio.create_task(self.report_mode())
|
|
||||||
else:
|
|
||||||
self._client.stop_report_mode()
|
|
||||||
|
|
||||||
async def report_mode(self):
|
|
||||||
async for report in self._client.report_mode():
|
|
||||||
self.report_update.emit(report)
|
|
||||||
self.interval = [
|
|
||||||
self.report[i]["interval"] for i in range(len(self.report))
|
|
||||||
]
|
|
||||||
|
|
||||||
async def end_session(self):
|
|
||||||
await self._client.end_session()
|
|
||||||
|
|
||||||
async def set_ipv4(self, ipv4):
|
|
||||||
await self._client.set_param("ipv4", ipv4)
|
|
||||||
|
|
||||||
async def get_ipv4(self):
|
|
||||||
return await self._client.ipv4()
|
|
||||||
|
|
||||||
@asyncSlot()
|
|
||||||
async def save_cfg(self, ch):
|
|
||||||
await self._client.save_config(ch)
|
|
||||||
self.info_box_trigger.emit(
|
|
||||||
"Config saved", f"Channel {ch} Config has been saved from flash."
|
|
||||||
)
|
|
||||||
|
|
||||||
@asyncSlot()
|
|
||||||
async def load_cfg(self, ch):
|
|
||||||
await self._client.load_config(ch)
|
|
||||||
self.info_box_trigger.emit(
|
|
||||||
"Config loaded", f"Channel {ch} Config has been loaded from flash."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def dfu(self):
|
|
||||||
await self._client.dfu()
|
|
||||||
|
|
||||||
async def reset(self):
|
|
||||||
await self._client.reset()
|
|
||||||
|
|
||||||
@pyqtSlot(float)
|
|
||||||
def set_update_s(self, update_s):
|
|
||||||
self._update_s = update_s
|
|
Binary file not shown.
Before Width: | Height: | Size: 131 KiB |
@ -1,56 +0,0 @@
|
|||||||
from PyQt6 import QtWidgets, QtCore
|
|
||||||
|
|
||||||
|
|
||||||
class ConnMenu(QtWidgets.QMenu):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.setTitle("Connection Settings")
|
|
||||||
|
|
||||||
self.host_set_line = QtWidgets.QLineEdit()
|
|
||||||
self.host_set_line.setMinimumSize(QtCore.QSize(160, 0))
|
|
||||||
self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215))
|
|
||||||
self.host_set_line.setMaxLength(15)
|
|
||||||
self.host_set_line.setClearButtonEnabled(True)
|
|
||||||
|
|
||||||
def connect_on_enter_press():
|
|
||||||
self.connect_btn.click()
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
self.host_set_line.returnPressed.connect(connect_on_enter_press)
|
|
||||||
|
|
||||||
self.host_set_line.setText("192.168.1.26")
|
|
||||||
self.host_set_line.setPlaceholderText("IP for the Thermostat")
|
|
||||||
|
|
||||||
host = QtWidgets.QWidgetAction(self)
|
|
||||||
host.setDefaultWidget(self.host_set_line)
|
|
||||||
self.addAction(host)
|
|
||||||
self.host = host
|
|
||||||
|
|
||||||
self.port_set_spin = QtWidgets.QSpinBox()
|
|
||||||
self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0))
|
|
||||||
self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215))
|
|
||||||
self.port_set_spin.setMaximum(65535)
|
|
||||||
self.port_set_spin.setValue(23)
|
|
||||||
|
|
||||||
def connect_only_if_enter_pressed():
|
|
||||||
if (
|
|
||||||
not self.port_set_spin.hasFocus()
|
|
||||||
): # Don't connect if the spinbox only lost focus
|
|
||||||
return
|
|
||||||
connect_on_enter_press()
|
|
||||||
|
|
||||||
self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
|
|
||||||
|
|
||||||
port = QtWidgets.QWidgetAction(self)
|
|
||||||
port.setDefaultWidget(self.port_set_spin)
|
|
||||||
self.addAction(port)
|
|
||||||
self.port = port
|
|
||||||
|
|
||||||
self.exit_button = QtWidgets.QPushButton()
|
|
||||||
self.exit_button.setText("Exit GUI")
|
|
||||||
self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
|
|
||||||
|
|
||||||
exit_action = QtWidgets.QWidgetAction(self.exit_button)
|
|
||||||
exit_action.setDefaultWidget(self.exit_button)
|
|
||||||
self.addAction(exit_action)
|
|
||||||
self.exit_action = exit_action
|
|
@ -1,202 +0,0 @@
|
|||||||
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
|
|
||||||
import pyqtgraph.parametertree.parameterTypes as pTypes
|
|
||||||
from pyqtgraph.parametertree import (
|
|
||||||
Parameter,
|
|
||||||
registerParameterType,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MutexParameter(pTypes.ListParameter):
|
|
||||||
"""
|
|
||||||
Mutually exclusive parameter where only one of its children is visible at a time, list selectable.
|
|
||||||
|
|
||||||
The ordering of the list items determines which children will be visible.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, **opts):
|
|
||||||
super().__init__(**opts)
|
|
||||||
|
|
||||||
self.sigValueChanged.connect(self.show_chosen_child)
|
|
||||||
self.sigValueChanged.emit(self, self.opts["value"])
|
|
||||||
|
|
||||||
def _get_param_from_value(self, value):
|
|
||||||
if isinstance(self.opts["limits"], dict):
|
|
||||||
values_list = list(self.opts["limits"].values())
|
|
||||||
else:
|
|
||||||
values_list = self.opts["limits"]
|
|
||||||
|
|
||||||
return self.children()[values_list.index(value)]
|
|
||||||
|
|
||||||
@pyqtSlot(object, object)
|
|
||||||
def show_chosen_child(self, value):
|
|
||||||
for param in self.children():
|
|
||||||
param.hide()
|
|
||||||
|
|
||||||
child_to_show = self._get_param_from_value(value.value())
|
|
||||||
child_to_show.show()
|
|
||||||
|
|
||||||
if child_to_show.opts.get("triggerOnShow", None):
|
|
||||||
child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value())
|
|
||||||
|
|
||||||
|
|
||||||
registerParameterType("mutex", MutexParameter)
|
|
||||||
|
|
||||||
|
|
||||||
class CtrlPanel(QObject):
|
|
||||||
set_zero_limits_warning_sig = pyqtSignal(list)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
trees_ui,
|
|
||||||
param_tree,
|
|
||||||
sigTreeStateChanged_handle,
|
|
||||||
sigActivated_handles,
|
|
||||||
parent=None,
|
|
||||||
):
|
|
||||||
super().__init__(parent)
|
|
||||||
|
|
||||||
self.trees_ui = trees_ui
|
|
||||||
self.NUM_CHANNELS = len(trees_ui)
|
|
||||||
|
|
||||||
self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)]
|
|
||||||
|
|
||||||
self.params = [
|
|
||||||
Parameter.create(
|
|
||||||
name=f"Thermostat Channel {ch} Parameters",
|
|
||||||
type="group",
|
|
||||||
value=ch,
|
|
||||||
children=self.THERMOSTAT_PARAMETERS[ch],
|
|
||||||
)
|
|
||||||
for ch in range(self.NUM_CHANNELS)
|
|
||||||
]
|
|
||||||
|
|
||||||
for i, param in enumerate(self.params):
|
|
||||||
param.channel = i
|
|
||||||
|
|
||||||
for i, tree in enumerate(self.trees_ui):
|
|
||||||
tree.setHeaderHidden(True)
|
|
||||||
tree.setParameters(self.params[i], showTop=False)
|
|
||||||
self.params[i].setValue = self._setValue
|
|
||||||
self.params[i].sigTreeStateChanged.connect(sigTreeStateChanged_handle)
|
|
||||||
|
|
||||||
for handle in sigActivated_handles[i]:
|
|
||||||
self.params[i].child(*handle[0]).sigActivated.connect(handle[1])
|
|
||||||
|
|
||||||
def _setValue(self, value, blockSignal=None):
|
|
||||||
"""
|
|
||||||
Implement 'lock' mechanism for Parameter Type
|
|
||||||
|
|
||||||
Modified from the source
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if blockSignal is not None:
|
|
||||||
self.sigValueChanged.disconnect(blockSignal)
|
|
||||||
value = self._interpretValue(value)
|
|
||||||
if fn.eq(self.opts["value"], value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
if "lock" in self.opts.keys():
|
|
||||||
if self.opts["lock"]:
|
|
||||||
return value
|
|
||||||
self.opts["value"] = value
|
|
||||||
self.sigValueChanged.emit(
|
|
||||||
self, value
|
|
||||||
) # value might change after signal is received by tree item
|
|
||||||
finally:
|
|
||||||
if blockSignal is not None:
|
|
||||||
self.sigValueChanged.connect(blockSignal)
|
|
||||||
|
|
||||||
return self.opts["value"]
|
|
||||||
|
|
||||||
def change_params_title(self, channel, path, title):
|
|
||||||
self.params[channel].child(*path).setOpts(title=title)
|
|
||||||
|
|
||||||
@pyqtSlot("QVariantList")
|
|
||||||
def update_pid(self, pid_settings):
|
|
||||||
for settings in pid_settings:
|
|
||||||
channel = settings["channel"]
|
|
||||||
with QSignalBlocker(self.params[channel]):
|
|
||||||
self.params[channel].child("PID Config", "Kp").setValue(
|
|
||||||
settings["parameters"]["kp"]
|
|
||||||
)
|
|
||||||
self.params[channel].child("PID Config", "Ki").setValue(
|
|
||||||
settings["parameters"]["ki"]
|
|
||||||
)
|
|
||||||
self.params[channel].child("PID Config", "Kd").setValue(
|
|
||||||
settings["parameters"]["kd"]
|
|
||||||
)
|
|
||||||
self.params[channel].child(
|
|
||||||
"PID Config", "PID Output Clamping", "Minimum"
|
|
||||||
).setValue(settings["parameters"]["output_min"] * 1000)
|
|
||||||
self.params[channel].child(
|
|
||||||
"PID Config", "PID Output Clamping", "Maximum"
|
|
||||||
).setValue(settings["parameters"]["output_max"] * 1000)
|
|
||||||
self.params[channel].child(
|
|
||||||
"Output Config", "Control Method", "Set Temperature"
|
|
||||||
).setValue(settings["target"])
|
|
||||||
|
|
||||||
@pyqtSlot("QVariantList")
|
|
||||||
def update_report(self, report_data):
|
|
||||||
for settings in report_data:
|
|
||||||
channel = settings["channel"]
|
|
||||||
with QSignalBlocker(self.params[channel]):
|
|
||||||
self.params[channel].child("Output Config", "Control Method").setValue(
|
|
||||||
"Temperature PID" if settings["pid_engaged"] else "Constant Current"
|
|
||||||
)
|
|
||||||
self.params[channel].child(
|
|
||||||
"Output Config", "Control Method", "Set Current"
|
|
||||||
).setValue(settings["i_set"] * 1000)
|
|
||||||
if settings["temperature"] is not None:
|
|
||||||
self.params[channel].child("Temperature").setValue(
|
|
||||||
settings["temperature"]
|
|
||||||
)
|
|
||||||
if settings["tec_i"] is not None:
|
|
||||||
self.params[channel].child("Current through TEC").setValue(
|
|
||||||
settings["tec_i"] * 1000
|
|
||||||
)
|
|
||||||
|
|
||||||
@pyqtSlot("QVariantList")
|
|
||||||
def update_thermistor(self, sh_data):
|
|
||||||
for sh_param in sh_data:
|
|
||||||
channel = sh_param["channel"]
|
|
||||||
with QSignalBlocker(self.params[channel]):
|
|
||||||
self.params[channel].child("Thermistor Config", "T₀").setValue(
|
|
||||||
sh_param["params"]["t0"] - 273.15
|
|
||||||
)
|
|
||||||
self.params[channel].child("Thermistor Config", "R₀").setValue(
|
|
||||||
sh_param["params"]["r0"]
|
|
||||||
)
|
|
||||||
self.params[channel].child("Thermistor Config", "B").setValue(
|
|
||||||
sh_param["params"]["b"]
|
|
||||||
)
|
|
||||||
|
|
||||||
@pyqtSlot("QVariantList")
|
|
||||||
def update_pwm(self, pwm_data):
|
|
||||||
channels_zeroed_limits = [set() for i in range(self.NUM_CHANNELS)]
|
|
||||||
|
|
||||||
for pwm_params in pwm_data:
|
|
||||||
channel = pwm_params["channel"]
|
|
||||||
with QSignalBlocker(self.params[channel]):
|
|
||||||
self.params[channel].child(
|
|
||||||
"Output Config", "Limits", "Max Voltage Difference"
|
|
||||||
).setValue(pwm_params["max_v"]["value"])
|
|
||||||
self.params[channel].child(
|
|
||||||
"Output Config", "Limits", "Max Cooling Current"
|
|
||||||
).setValue(pwm_params["max_i_pos"]["value"] * 1000)
|
|
||||||
self.params[channel].child(
|
|
||||||
"Output Config", "Limits", "Max Heating Current"
|
|
||||||
).setValue(pwm_params["max_i_neg"]["value"] * 1000)
|
|
||||||
|
|
||||||
for limit in "max_i_pos", "max_i_neg", "max_v":
|
|
||||||
if pwm_params[limit]["value"] == 0.0:
|
|
||||||
channels_zeroed_limits[channel].add(limit)
|
|
||||||
self.set_zero_limits_warning_sig.emit(channels_zeroed_limits)
|
|
||||||
|
|
||||||
@pyqtSlot("QVariantList")
|
|
||||||
def update_postfilter(self, postfilter_data):
|
|
||||||
for postfilter_params in postfilter_data:
|
|
||||||
channel = postfilter_params["channel"]
|
|
||||||
with QSignalBlocker(self.params[channel]):
|
|
||||||
self.params[channel].child(
|
|
||||||
"Thermistor Config", "Postfilter Rate"
|
|
||||||
).setValue(postfilter_params["rate"])
|
|
@ -1,14 +0,0 @@
|
|||||||
from PyQt6 import QtWidgets
|
|
||||||
from PyQt6.QtCore import pyqtSlot
|
|
||||||
|
|
||||||
|
|
||||||
class InfoBox(QtWidgets.QMessageBox):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
|
||||||
|
|
||||||
@pyqtSlot(str, str)
|
|
||||||
def display_info_box(self, title, text):
|
|
||||||
self.setWindowTitle(title)
|
|
||||||
self.setText(text)
|
|
||||||
self.show()
|
|
@ -1,169 +0,0 @@
|
|||||||
from PyQt6.QtCore import QObject, pyqtSlot
|
|
||||||
from pglive.sources.data_connector import DataConnector
|
|
||||||
from pglive.kwargs import Axis
|
|
||||||
from pglive.sources.live_plot import LiveLinePlot
|
|
||||||
from pglive.sources.live_axis import LiveAxis
|
|
||||||
from collections import deque
|
|
||||||
import pyqtgraph as pg
|
|
||||||
|
|
||||||
pg.setConfigOptions(antialias=True)
|
|
||||||
|
|
||||||
|
|
||||||
class LiveDataPlotter(QObject):
|
|
||||||
def __init__(self, live_plots):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self.NUM_CHANNELS = len(live_plots)
|
|
||||||
self.graphs = []
|
|
||||||
|
|
||||||
for i, live_plot in enumerate(live_plots):
|
|
||||||
live_plot[0].setTitle(f"Channel {i} Temperature")
|
|
||||||
live_plot[1].setTitle(f"Channel {i} Current")
|
|
||||||
self.graphs.append(_TecGraphs(live_plot[0], live_plot[1]))
|
|
||||||
|
|
||||||
def _config_connector_max_pts(self, connector, samples):
|
|
||||||
connector.max_points = samples
|
|
||||||
connector.x = deque(maxlen=int(connector.max_points))
|
|
||||||
connector.y = deque(maxlen=int(connector.max_points))
|
|
||||||
|
|
||||||
@pyqtSlot(int)
|
|
||||||
def set_max_samples(self, samples: int):
|
|
||||||
for graph in self.graphs:
|
|
||||||
self._config_connector_max_pts(graph.t_connector, samples)
|
|
||||||
self._config_connector_max_pts(graph.i_connector, samples)
|
|
||||||
self._config_connector_max_pts(graph.iset_connector, samples)
|
|
||||||
|
|
||||||
@pyqtSlot()
|
|
||||||
def clear_graphs(self):
|
|
||||||
for graph in self.graphs:
|
|
||||||
graph.clear()
|
|
||||||
|
|
||||||
@pyqtSlot(list)
|
|
||||||
def update_pid(self, pid_settings):
|
|
||||||
for settings in pid_settings:
|
|
||||||
channel = settings["channel"]
|
|
||||||
self.graphs[channel].update_pid(settings)
|
|
||||||
|
|
||||||
@pyqtSlot(list)
|
|
||||||
def update_report(self, report_data):
|
|
||||||
for settings in report_data:
|
|
||||||
channel = settings["channel"]
|
|
||||||
self.graphs[channel].update_report(settings)
|
|
||||||
|
|
||||||
|
|
||||||
class _TecGraphs:
|
|
||||||
"""The maximum number of sample points to store."""
|
|
||||||
|
|
||||||
DEFAULT_MAX_SAMPLES = 1000
|
|
||||||
|
|
||||||
def __init__(self, t_widget, i_widget):
|
|
||||||
self._t_widget = t_widget
|
|
||||||
self._i_widget = i_widget
|
|
||||||
|
|
||||||
self._t_plot = LiveLinePlot()
|
|
||||||
self._i_plot = LiveLinePlot(name="Measured")
|
|
||||||
self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen("r"))
|
|
||||||
|
|
||||||
self._t_line = self._t_widget.getPlotItem().addLine(label="{value} °C")
|
|
||||||
self._t_line.setVisible(False)
|
|
||||||
# Hack for keeping setpoint line in plot range
|
|
||||||
self._t_setpoint_plot = (
|
|
||||||
LiveLinePlot()
|
|
||||||
)
|
|
||||||
|
|
||||||
for graph in t_widget, i_widget:
|
|
||||||
time_axis = LiveAxis(
|
|
||||||
"bottom",
|
|
||||||
text="Time since Thermostat reset",
|
|
||||||
**{Axis.TICK_FORMAT: Axis.DURATION},
|
|
||||||
)
|
|
||||||
time_axis.showLabel()
|
|
||||||
graph.setAxisItems({"bottom": time_axis})
|
|
||||||
|
|
||||||
graph.add_crosshair(pg.mkPen(color="red", width=1), {"color": "green"})
|
|
||||||
|
|
||||||
# Enable linking of axes in the graph widget's context menu
|
|
||||||
graph.register(
|
|
||||||
graph.getPlotItem().titleLabel.text # Slight hack getting the title
|
|
||||||
)
|
|
||||||
|
|
||||||
temperature_axis = LiveAxis("left", text="Temperature", units="°C")
|
|
||||||
temperature_axis.showLabel()
|
|
||||||
t_widget.setAxisItems({"left": temperature_axis})
|
|
||||||
|
|
||||||
current_axis = LiveAxis("left", text="Current", units="A")
|
|
||||||
current_axis.showLabel()
|
|
||||||
i_widget.setAxisItems({"left": current_axis})
|
|
||||||
i_widget.addLegend(brush=(50, 50, 200, 150))
|
|
||||||
|
|
||||||
t_widget.addItem(self._t_plot)
|
|
||||||
t_widget.addItem(self._t_setpoint_plot)
|
|
||||||
i_widget.addItem(self._i_plot)
|
|
||||||
i_widget.addItem(self._iset_plot)
|
|
||||||
|
|
||||||
self.t_connector = DataConnector(
|
|
||||||
self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES
|
|
||||||
)
|
|
||||||
self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1)
|
|
||||||
self.i_connector = DataConnector(
|
|
||||||
self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES
|
|
||||||
)
|
|
||||||
self.iset_connector = DataConnector(
|
|
||||||
self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES
|
|
||||||
)
|
|
||||||
|
|
||||||
self.max_samples = self.DEFAULT_MAX_SAMPLES
|
|
||||||
|
|
||||||
def plot_append(self, report):
|
|
||||||
temperature = report["temperature"]
|
|
||||||
current = report["tec_i"]
|
|
||||||
iset = report["i_set"]
|
|
||||||
time = report["time"]
|
|
||||||
|
|
||||||
if temperature is not None:
|
|
||||||
self.t_connector.cb_append_data_point(temperature, time)
|
|
||||||
if self._t_line.isVisible():
|
|
||||||
self.t_setpoint_connector.cb_append_data_point(
|
|
||||||
self._t_line.value(), time
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.t_setpoint_connector.cb_append_data_point(temperature, time)
|
|
||||||
if current is not None:
|
|
||||||
self.i_connector.cb_append_data_point(current, time)
|
|
||||||
self.iset_connector.cb_append_data_point(iset, time)
|
|
||||||
|
|
||||||
def set_max_sample(self, samples: int):
|
|
||||||
for connector in self.t_connector, self.i_connector, self.iset_connector:
|
|
||||||
connector.max_points(samples)
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
for connector in self.t_connector, self.i_connector, self.iset_connector:
|
|
||||||
connector.clear()
|
|
||||||
|
|
||||||
def set_t_line(self, temp=None, visible=None):
|
|
||||||
if visible is not None:
|
|
||||||
self._t_line.setVisible(visible)
|
|
||||||
if temp is not None:
|
|
||||||
self._t_line.setValue(temp)
|
|
||||||
|
|
||||||
# PyQtGraph normally does not update this text when the line
|
|
||||||
# is not visible, so make sure that the temperature label
|
|
||||||
# gets updated always, and doesn't stay at an old value.
|
|
||||||
self._t_line.label.setText(f"{temp} °C")
|
|
||||||
|
|
||||||
def set_max_samples(self, samples: int):
|
|
||||||
for graph in self.graphs:
|
|
||||||
graph.t_connector.max_points = samples
|
|
||||||
graph.i_connector.max_points = samples
|
|
||||||
graph.iset_connector.max_points = samples
|
|
||||||
|
|
||||||
def clear_graphs(self):
|
|
||||||
for graph in self.graphs:
|
|
||||||
graph.clear()
|
|
||||||
|
|
||||||
def update_pid(self, pid_settings):
|
|
||||||
self.set_t_line(temp=round(pid_settings["target"], 6))
|
|
||||||
|
|
||||||
def update_report(self, report_data):
|
|
||||||
self.plot_append(report_data)
|
|
||||||
self.set_t_line(visible=report_data["pid_engaged"])
|
|
@ -1,36 +0,0 @@
|
|||||||
from PyQt6 import QtWidgets
|
|
||||||
from PyQt6.QtWidgets import QAbstractButton
|
|
||||||
from PyQt6.QtCore import pyqtSignal, pyqtSlot
|
|
||||||
|
|
||||||
|
|
||||||
class NetSettingsInputDiag(QtWidgets.QInputDialog):
|
|
||||||
set_ipv4_act = pyqtSignal(str)
|
|
||||||
|
|
||||||
def __init__(self, current_ipv4_settings):
|
|
||||||
super().__init__()
|
|
||||||
self.setWindowTitle("Network Settings")
|
|
||||||
self.setLabelText(
|
|
||||||
"Set the Thermostat's IPv4 address, netmask and gateway (optional)"
|
|
||||||
)
|
|
||||||
self.setTextValue(current_ipv4_settings)
|
|
||||||
self._new_ipv4 = ""
|
|
||||||
|
|
||||||
@pyqtSlot(str)
|
|
||||||
def set_ipv4(ipv4_settings):
|
|
||||||
self._new_ipv4 = ipv4_settings
|
|
||||||
|
|
||||||
sure = QtWidgets.QMessageBox(self)
|
|
||||||
sure.setWindowTitle("Set network?")
|
|
||||||
sure.setText(
|
|
||||||
f"Setting this as network and disconnecting:<br>{ipv4_settings}"
|
|
||||||
)
|
|
||||||
|
|
||||||
sure.buttonClicked.connect(self._emit_sig)
|
|
||||||
sure.show()
|
|
||||||
|
|
||||||
self.textValueSelected.connect(set_ipv4)
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
@pyqtSlot(QAbstractButton)
|
|
||||||
def _emit_sig(self, _):
|
|
||||||
self.set_ipv4_act.emit(self._new_ipv4)
|
|
@ -1,335 +0,0 @@
|
|||||||
{
|
|
||||||
"ctrl_panel":[
|
|
||||||
{
|
|
||||||
"name":"Temperature",
|
|
||||||
"type":"float",
|
|
||||||
"format":"{value:.4f} °C",
|
|
||||||
"readonly":true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Current through TEC",
|
|
||||||
"type":"float",
|
|
||||||
"suffix":"mA",
|
|
||||||
"decimals":6,
|
|
||||||
"readonly":true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Output Config",
|
|
||||||
"expanded":true,
|
|
||||||
"type":"group",
|
|
||||||
"children":[
|
|
||||||
{
|
|
||||||
"name":"Control Method",
|
|
||||||
"type":"mutex",
|
|
||||||
"limits":[
|
|
||||||
"Constant Current",
|
|
||||||
"Temperature PID"
|
|
||||||
],
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pwm",
|
|
||||||
"field":"pid"
|
|
||||||
},
|
|
||||||
"children":[
|
|
||||||
{
|
|
||||||
"name":"Set Current",
|
|
||||||
"type":"float",
|
|
||||||
"value":0,
|
|
||||||
"step":100,
|
|
||||||
"limits":[
|
|
||||||
-2000,
|
|
||||||
2000
|
|
||||||
],
|
|
||||||
"triggerOnShow":true,
|
|
||||||
"decimals":6,
|
|
||||||
"suffix":"mA",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pwm",
|
|
||||||
"field":"i_set"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Set Temperature",
|
|
||||||
"type":"float",
|
|
||||||
"value":25,
|
|
||||||
"step":0.1,
|
|
||||||
"limits":[
|
|
||||||
-273,
|
|
||||||
300
|
|
||||||
],
|
|
||||||
"format":"{value:.4f} °C",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pid",
|
|
||||||
"field":"target"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Limits",
|
|
||||||
"expanded":true,
|
|
||||||
"type":"group",
|
|
||||||
"children":[
|
|
||||||
{
|
|
||||||
"name":"Max Cooling Current",
|
|
||||||
"type":"float",
|
|
||||||
"value":0,
|
|
||||||
"step":100,
|
|
||||||
"decimals":6,
|
|
||||||
"limits":[
|
|
||||||
0,
|
|
||||||
2000
|
|
||||||
],
|
|
||||||
"suffix":"mA",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pwm",
|
|
||||||
"field":"max_i_pos"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Max Heating Current",
|
|
||||||
"type":"float",
|
|
||||||
"value":0,
|
|
||||||
"step":100,
|
|
||||||
"decimals":6,
|
|
||||||
"limits":[
|
|
||||||
0,
|
|
||||||
2000
|
|
||||||
],
|
|
||||||
"suffix":"mA",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pwm",
|
|
||||||
"field":"max_i_neg"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Max Voltage Difference",
|
|
||||||
"type":"float",
|
|
||||||
"value":0,
|
|
||||||
"step":0.1,
|
|
||||||
"limits":[
|
|
||||||
0,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"siPrefix":true,
|
|
||||||
"suffix":"V",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pwm",
|
|
||||||
"field":"max_v"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Thermistor Config",
|
|
||||||
"expanded":true,
|
|
||||||
"type":"group",
|
|
||||||
"children":[
|
|
||||||
{
|
|
||||||
"name":"T₀",
|
|
||||||
"type":"float",
|
|
||||||
"value":25,
|
|
||||||
"step":0.1,
|
|
||||||
"limits":[
|
|
||||||
-100,
|
|
||||||
100
|
|
||||||
],
|
|
||||||
"format":"{value:.4f} °C",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"s-h",
|
|
||||||
"field":"t0"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"R₀",
|
|
||||||
"type":"float",
|
|
||||||
"value":10000,
|
|
||||||
"step":1,
|
|
||||||
"siPrefix":true,
|
|
||||||
"suffix":"Ω",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"s-h",
|
|
||||||
"field":"r0"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"B",
|
|
||||||
"type":"float",
|
|
||||||
"value":3950,
|
|
||||||
"step":1,
|
|
||||||
"suffix":"K",
|
|
||||||
"decimals":4,
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"s-h",
|
|
||||||
"field":"b"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Postfilter Rate",
|
|
||||||
"type":"list",
|
|
||||||
"value":16.67,
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"postfilter",
|
|
||||||
"field":"rate"
|
|
||||||
},
|
|
||||||
"limits":{
|
|
||||||
"Off":null,
|
|
||||||
"16.67 Hz":16.67,
|
|
||||||
"20 Hz":20.0,
|
|
||||||
"21.25 Hz":21.25,
|
|
||||||
"27 Hz":27.0
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"PID Config",
|
|
||||||
"expanded":true,
|
|
||||||
"type":"group",
|
|
||||||
"children":[
|
|
||||||
{
|
|
||||||
"name":"Kp",
|
|
||||||
"type":"float",
|
|
||||||
"step":0.1,
|
|
||||||
"suffix":"",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pid",
|
|
||||||
"field":"kp"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Ki",
|
|
||||||
"type":"float",
|
|
||||||
"step":0.1,
|
|
||||||
"suffix":"Hz",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pid",
|
|
||||||
"field":"ki"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Kd",
|
|
||||||
"type":"float",
|
|
||||||
"step":0.1,
|
|
||||||
"suffix":"s",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pid",
|
|
||||||
"field":"kd"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"PID Output Clamping",
|
|
||||||
"expanded":true,
|
|
||||||
"type":"group",
|
|
||||||
"children":[
|
|
||||||
{
|
|
||||||
"name":"Minimum",
|
|
||||||
"type":"float",
|
|
||||||
"step":100,
|
|
||||||
"limits":[
|
|
||||||
-2000,
|
|
||||||
2000
|
|
||||||
],
|
|
||||||
"decimals":6,
|
|
||||||
"suffix":"mA",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pid",
|
|
||||||
"field":"output_min"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Maximum",
|
|
||||||
"type":"float",
|
|
||||||
"step":100,
|
|
||||||
"limits":[
|
|
||||||
-2000,
|
|
||||||
2000
|
|
||||||
],
|
|
||||||
"decimals":6,
|
|
||||||
"suffix":"mA",
|
|
||||||
"thermostat:set_param":{
|
|
||||||
"topic":"pid",
|
|
||||||
"field":"output_max"
|
|
||||||
},
|
|
||||||
"lock":false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"PID Auto Tune",
|
|
||||||
"expanded":false,
|
|
||||||
"type":"group",
|
|
||||||
"children":[
|
|
||||||
{
|
|
||||||
"name":"Target Temperature",
|
|
||||||
"type":"float",
|
|
||||||
"value":20,
|
|
||||||
"step":0.1,
|
|
||||||
"format":"{value:.4f} °C",
|
|
||||||
"pid_autotune":"target_temp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Test Current",
|
|
||||||
"type":"float",
|
|
||||||
"value":0,
|
|
||||||
"decimals":6,
|
|
||||||
"step":100,
|
|
||||||
"limits":[
|
|
||||||
-2000,
|
|
||||||
2000
|
|
||||||
],
|
|
||||||
"suffix":"mA",
|
|
||||||
"pid_autotune":"test_current"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Temperature Swing",
|
|
||||||
"type":"float",
|
|
||||||
"value":1.5,
|
|
||||||
"step":0.1,
|
|
||||||
"prefix":"±",
|
|
||||||
"format":"{value:.4f} °C",
|
|
||||||
"pid_autotune":"temp_swing"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Lookback",
|
|
||||||
"type":"float",
|
|
||||||
"value":3.0,
|
|
||||||
"step":0.1,
|
|
||||||
"format":"{value:.4f} s",
|
|
||||||
"pid_autotune":"lookback"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Run",
|
|
||||||
"type":"action",
|
|
||||||
"tip":"Run"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Save to flash",
|
|
||||||
"type":"action",
|
|
||||||
"tip":"Save config to thermostat, applies on reset"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"Load from flash",
|
|
||||||
"type":"action",
|
|
||||||
"tip":"Load config from flash"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
from PyQt6 import QtWidgets, QtGui
|
|
||||||
|
|
||||||
|
|
||||||
class PlotOptionsMenu(QtWidgets.QMenu):
|
|
||||||
def __init__(self, max_samples=1000):
|
|
||||||
super().__init__()
|
|
||||||
self.setTitle("Plot Settings")
|
|
||||||
|
|
||||||
clear = QtGui.QAction("Clear graphs", self)
|
|
||||||
self.addAction(clear)
|
|
||||||
self.clear = clear
|
|
||||||
|
|
||||||
self.samples_spinbox = QtWidgets.QSpinBox()
|
|
||||||
self.samples_spinbox.setRange(2, 100000)
|
|
||||||
self.samples_spinbox.setSuffix(" samples")
|
|
||||||
self.samples_spinbox.setValue(max_samples)
|
|
||||||
|
|
||||||
limit_samples = QtWidgets.QWidgetAction(self)
|
|
||||||
limit_samples.setDefaultWidget(self.samples_spinbox)
|
|
||||||
self.addAction(limit_samples)
|
|
||||||
self.limit_samples = limit_samples
|
|
@ -1,597 +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>../resources/artiq.ico</normaloff>../resources/artiq.ico</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::Shape::StyledPanel</enum>
|
|
||||||
</property>
|
|
||||||
<property name="frameShadow">
|
|
||||||
<enum>QFrame::Shadow::Raised</enum>
|
|
||||||
</property>
|
|
||||||
<layout class="QGridLayout" name="graphs_layout" rowstretch="1,1" columnstretch="1,1,1" rowminimumheight="100,100" columnminimumwidth="100,100,100">
|
|
||||||
<property name="sizeConstraint">
|
|
||||||
<enum>QLayout::SizeConstraint::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="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>
|
|
||||||
<item row="0" column="0" rowspan="2">
|
|
||||||
<widget class="QTabWidget" name="tabWidget">
|
|
||||||
<property name="sizePolicy">
|
|
||||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
|
||||||
<horstretch>0</horstretch>
|
|
||||||
<verstretch>0</verstretch>
|
|
||||||
</sizepolicy>
|
|
||||||
</property>
|
|
||||||
<property name="currentIndex">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<widget class="QWidget" name="ch0_tab">
|
|
||||||
<property name="sizePolicy">
|
|
||||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
|
||||||
<horstretch>0</horstretch>
|
|
||||||
<verstretch>0</verstretch>
|
|
||||||
</sizepolicy>
|
|
||||||
</property>
|
|
||||||
<attribute name="title">
|
|
||||||
<string>Channel 0</string>
|
|
||||||
</attribute>
|
|
||||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
|
||||||
<item>
|
|
||||||
<widget class="ParameterTree" name="ch0_tree" native="true">
|
|
||||||
<property name="sizePolicy">
|
|
||||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
|
||||||
<horstretch>0</horstretch>
|
|
||||||
<verstretch>0</verstretch>
|
|
||||||
</sizepolicy>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
</layout>
|
|
||||||
</widget>
|
|
||||||
<widget class="QWidget" name="ch1_tab">
|
|
||||||
<property name="sizePolicy">
|
|
||||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
|
||||||
<horstretch>0</horstretch>
|
|
||||||
<verstretch>0</verstretch>
|
|
||||||
</sizepolicy>
|
|
||||||
</property>
|
|
||||||
<attribute name="title">
|
|
||||||
<string>Channel 1</string>
|
|
||||||
</attribute>
|
|
||||||
<layout class="QVBoxLayout" name="verticalLayout">
|
|
||||||
<item>
|
|
||||||
<widget class="ParameterTree" name="ch1_tree" native="true">
|
|
||||||
<property name="sizePolicy">
|
|
||||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
|
||||||
<horstretch>0</horstretch>
|
|
||||||
<verstretch>0</verstretch>
|
|
||||||
</sizepolicy>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
</layout>
|
|
||||||
</widget>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
</layout>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<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::Shape::StyledPanel</enum>
|
|
||||||
</property>
|
|
||||||
<property name="frameShadow">
|
|
||||||
<enum>QFrame::Shadow::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::ToolButtonPopupMode::MenuButtonPopup</enum>
|
|
||||||
</property>
|
|
||||||
<property name="toolButtonStyle">
|
|
||||||
<enum>Qt::ToolButtonStyle::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::ToolButtonPopupMode::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::ToolButtonPopupMode::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::Orientation::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::SizeConstraint::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::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::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::StepType::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::MenuRole::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::MenuRole::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::MenuRole::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::MenuRole::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::MenuRole::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::MenuRole::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>pytec.gui.view.waitingspinnerwidget</header>
|
|
||||||
<container>1</container>
|
|
||||||
</customwidget>
|
|
||||||
</customwidgets>
|
|
||||||
<resources/>
|
|
||||||
<connections/>
|
|
||||||
</ui>
|
|
@ -1,145 +0,0 @@
|
|||||||
from PyQt6 import QtWidgets, QtGui, QtCore
|
|
||||||
from PyQt6.QtCore import pyqtSignal, pyqtSlot
|
|
||||||
|
|
||||||
|
|
||||||
class ThermostatCtrlMenu(QtWidgets.QMenu):
|
|
||||||
fan_set_act = pyqtSignal(int)
|
|
||||||
fan_auto_set_act = pyqtSignal(int)
|
|
||||||
|
|
||||||
connect_act = pyqtSignal()
|
|
||||||
reset_act = pyqtSignal(bool)
|
|
||||||
dfu_act = pyqtSignal(bool)
|
|
||||||
load_cfg_act = pyqtSignal(int)
|
|
||||||
save_cfg_act = pyqtSignal(int)
|
|
||||||
net_cfg_act = pyqtSignal(bool)
|
|
||||||
|
|
||||||
def __init__(self, style):
|
|
||||||
super().__init__()
|
|
||||||
self._style = style
|
|
||||||
self.setTitle("Thermostat settings")
|
|
||||||
|
|
||||||
self.hw_rev_data = dict()
|
|
||||||
|
|
||||||
self.fan_group = QtWidgets.QWidget()
|
|
||||||
self.fan_group.setEnabled(False)
|
|
||||||
self.fan_group.setMinimumSize(QtCore.QSize(40, 0))
|
|
||||||
self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group)
|
|
||||||
self.fan_layout.setSpacing(9)
|
|
||||||
self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group)
|
|
||||||
self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0))
|
|
||||||
self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215))
|
|
||||||
self.fan_lbl.setBaseSize(QtCore.QSize(40, 0))
|
|
||||||
self.fan_layout.addWidget(self.fan_lbl)
|
|
||||||
self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group)
|
|
||||||
self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0))
|
|
||||||
self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215))
|
|
||||||
self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0))
|
|
||||||
self.fan_power_slider.setRange(1, 100)
|
|
||||||
self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal)
|
|
||||||
self.fan_layout.addWidget(self.fan_power_slider)
|
|
||||||
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
|
|
||||||
self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0))
|
|
||||||
self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215))
|
|
||||||
self.fan_layout.addWidget(self.fan_auto_box)
|
|
||||||
self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
|
|
||||||
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
|
|
||||||
self.fan_layout.addWidget(self.fan_pwm_warning)
|
|
||||||
|
|
||||||
self.fan_power_slider.valueChanged.connect(self.fan_set_act)
|
|
||||||
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_act)
|
|
||||||
|
|
||||||
self.fan_lbl.setToolTip("Adjust the fan")
|
|
||||||
self.fan_lbl.setText("Fan:")
|
|
||||||
self.fan_auto_box.setText("Auto")
|
|
||||||
|
|
||||||
fan = QtWidgets.QWidgetAction(self)
|
|
||||||
fan.setDefaultWidget(self.fan_group)
|
|
||||||
self.addAction(fan)
|
|
||||||
self.fan = fan
|
|
||||||
|
|
||||||
self.actionReset = QtGui.QAction("Reset Thermostat", self)
|
|
||||||
self.actionReset.triggered.connect(self.reset_act)
|
|
||||||
self.addAction(self.actionReset)
|
|
||||||
|
|
||||||
self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self)
|
|
||||||
self.actionEnter_DFU_Mode.triggered.connect(self.dfu_act)
|
|
||||||
self.addAction(self.actionEnter_DFU_Mode)
|
|
||||||
|
|
||||||
self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self)
|
|
||||||
self.actionnet_settings_input_diag.triggered.connect(self.net_cfg_act)
|
|
||||||
self.addAction(self.actionnet_settings_input_diag)
|
|
||||||
|
|
||||||
@pyqtSlot(bool)
|
|
||||||
def load(_):
|
|
||||||
self.load_cfg_act.emit(0)
|
|
||||||
self.load_cfg_act.emit(1)
|
|
||||||
loaded = QtWidgets.QMessageBox(self)
|
|
||||||
loaded.setWindowTitle("Config loaded")
|
|
||||||
loaded.setText("All channel configs have been loaded from flash.")
|
|
||||||
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
|
||||||
loaded.show()
|
|
||||||
|
|
||||||
self.actionLoad_all_configs = QtGui.QAction("Load Config", self)
|
|
||||||
self.actionLoad_all_configs.triggered.connect(load)
|
|
||||||
self.addAction(self.actionLoad_all_configs)
|
|
||||||
|
|
||||||
@pyqtSlot(bool)
|
|
||||||
def save(_):
|
|
||||||
self.save_cfg_act.emit(0)
|
|
||||||
self.save_cfg_act.emit(1)
|
|
||||||
saved = QtWidgets.QMessageBox(self)
|
|
||||||
saved.setWindowTitle("Config saved")
|
|
||||||
saved.setText("All channel configs have been saved to flash.")
|
|
||||||
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
|
||||||
saved.show()
|
|
||||||
|
|
||||||
self.actionSave_all_configs = QtGui.QAction("Save Config", self)
|
|
||||||
self.actionSave_all_configs.triggered.connect(save)
|
|
||||||
self.addAction(self.actionSave_all_configs)
|
|
||||||
|
|
||||||
def about_thermostat():
|
|
||||||
QtWidgets.QMessageBox.about(
|
|
||||||
self,
|
|
||||||
"About Thermostat",
|
|
||||||
f"""
|
|
||||||
<h1>Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}</h1>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<h2>Settings:</h2>
|
|
||||||
Default fan curve:
|
|
||||||
a = {self.hw_rev_data['settings']['fan_k_a']},
|
|
||||||
b = {self.hw_rev_data['settings']['fan_k_b']},
|
|
||||||
c = {self.hw_rev_data['settings']['fan_k_c']}
|
|
||||||
<br>
|
|
||||||
Fan PWM range:
|
|
||||||
{self.hw_rev_data['settings']['min_fan_pwm']} \u2013 {self.hw_rev_data['settings']['max_fan_pwm']}
|
|
||||||
<br>
|
|
||||||
Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz
|
|
||||||
<br>
|
|
||||||
Fan available: {self.hw_rev_data['settings']['fan_available']}
|
|
||||||
<br>
|
|
||||||
Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']}
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.actionAbout_Thermostat = QtGui.QAction("About Thermostat", self)
|
|
||||||
self.actionAbout_Thermostat.triggered.connect(about_thermostat)
|
|
||||||
self.addAction(self.actionAbout_Thermostat)
|
|
||||||
|
|
||||||
def set_fan_pwm_warning(self):
|
|
||||||
if self.fan_power_slider.value() != 100:
|
|
||||||
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
|
|
||||||
icon = self._style.standardIcon(pixmapi)
|
|
||||||
self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
|
|
||||||
self.fan_pwm_warning.setToolTip(
|
|
||||||
"Throttling the fan (not recommended on this hardware rev)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
|
|
||||||
self.fan_pwm_warning.setToolTip("")
|
|
||||||
|
|
||||||
@pyqtSlot("QVariantMap")
|
|
||||||
def hw_rev(self, hw_rev):
|
|
||||||
self.hw_rev_data = hw_rev
|
|
||||||
self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"])
|
|
@ -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()
|
|
@ -1,41 +0,0 @@
|
|||||||
from PyQt6.QtCore import pyqtSlot, QObject
|
|
||||||
from PyQt6 import QtWidgets, QtGui
|
|
||||||
|
|
||||||
|
|
||||||
class ZeroLimitsWarningView(QObject):
|
|
||||||
def __init__(self, style, limit_warning):
|
|
||||||
super().__init__()
|
|
||||||
self._lbl = limit_warning
|
|
||||||
self._style = style
|
|
||||||
|
|
||||||
@pyqtSlot("QVariantList")
|
|
||||||
def set_limits_warning(self, channels_zeroed_limits: list):
|
|
||||||
channel_disabled = [False, False]
|
|
||||||
|
|
||||||
report_str = "The following output limit(s) are set to zero:\n"
|
|
||||||
for ch, zeroed_limits in enumerate(channels_zeroed_limits):
|
|
||||||
if {"max_i_pos", "max_i_neg"}.issubset(zeroed_limits):
|
|
||||||
report_str += "Max Cooling Current, Max Heating Current"
|
|
||||||
channel_disabled[ch] = True
|
|
||||||
|
|
||||||
if "max_v" in zeroed_limits:
|
|
||||||
if channel_disabled[ch]:
|
|
||||||
report_str += ", "
|
|
||||||
report_str += "Max Voltage Difference"
|
|
||||||
channel_disabled[ch] = True
|
|
||||||
|
|
||||||
if channel_disabled[ch]:
|
|
||||||
report_str += f" for Channel {ch}\n"
|
|
||||||
|
|
||||||
report_str += (
|
|
||||||
"\nThese limit(s) are restricting the channel(s) from producing current."
|
|
||||||
)
|
|
||||||
|
|
||||||
if True in channel_disabled:
|
|
||||||
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
|
|
||||||
icon = self._style.standardIcon(pixmapi)
|
|
||||||
self._lbl.setPixmap(icon.pixmap(16, 16))
|
|
||||||
self._lbl.setToolTip(report_str)
|
|
||||||
else:
|
|
||||||
self._lbl.setPixmap(QtGui.QPixmap())
|
|
||||||
self._lbl.setToolTip(None)
|
|
@ -9,10 +9,4 @@ setup(
|
|||||||
license="GPLv3",
|
license="GPLv3",
|
||||||
install_requires=["setuptools"],
|
install_requires=["setuptools"],
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
entry_points={
|
|
||||||
"gui_scripts": [
|
|
||||||
"tec_qt = tec_qt:main",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
py_modules=['autotune', 'plot', 'tec_qt'],
|
|
||||||
)
|
)
|
||||||
|
428
pytec/tec_qt.py
428
pytec/tec_qt.py
@ -1,428 +0,0 @@
|
|||||||
from pytec.gui.view.zero_limits_warning import ZeroLimitsWarningView
|
|
||||||
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
|
|
||||||
from pytec.gui.view.thermostat_ctrl_menu import ThermostatCtrlMenu
|
|
||||||
from pytec.gui.view.conn_menu import ConnMenu
|
|
||||||
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 pytec.gui.model.pid_autotuner import PIDAutoTuner
|
|
||||||
from pytec.gui.model.thermostat import WrappedClient, Thermostat
|
|
||||||
import json
|
|
||||||
from autotune import PIDAutotuneState
|
|
||||||
from qasync import asyncSlot, asyncClose
|
|
||||||
import qasync
|
|
||||||
from pytec.aioclient import StoppedConnecting
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import argparse
|
|
||||||
from PyQt6 import QtWidgets, QtGui, uic
|
|
||||||
from PyQt6.QtCore import QSignalBlocker, pyqtSlot
|
|
||||||
import pyqtgraph as pg
|
|
||||||
from functools import partial
|
|
||||||
import importlib.resources
|
|
||||||
|
|
||||||
|
|
||||||
def get_argparser():
|
|
||||||
parser = argparse.ArgumentParser(description="Thermostat Control Panel")
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-p",
|
|
||||||
"--param_tree",
|
|
||||||
default=importlib.resources.files("pytec.gui.view").joinpath("param_tree.json"),
|
|
||||||
help="Param Tree Description JSON File",
|
|
||||||
)
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QtWidgets.QMainWindow):
|
|
||||||
NUM_CHANNELS = 2
|
|
||||||
|
|
||||||
def __init__(self, args):
|
|
||||||
super(MainWindow, self).__init__()
|
|
||||||
|
|
||||||
ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui")
|
|
||||||
uic.loadUi(ui_file_path, self)
|
|
||||||
|
|
||||||
self.hw_rev_data = None
|
|
||||||
self.info_box = InfoBox()
|
|
||||||
|
|
||||||
self.client = WrappedClient(self)
|
|
||||||
self.client.connection_error.connect(self.bail)
|
|
||||||
|
|
||||||
self.thermostat = Thermostat(
|
|
||||||
self, self.client, self.report_refresh_spin.value()
|
|
||||||
)
|
|
||||||
|
|
||||||
self.autotuners = PIDAutoTuner(self, self.client, 2)
|
|
||||||
|
|
||||||
def get_ctrl_panel_config(args):
|
|
||||||
with open(args.param_tree, "r") as f:
|
|
||||||
return json.load(f)["ctrl_panel"]
|
|
||||||
|
|
||||||
param_tree_sigActivated_handles = [
|
|
||||||
[
|
|
||||||
[["Save to flash"], partial(self.thermostat.save_cfg, ch)],
|
|
||||||
[["Load from flash"], partial(self.thermostat.load_cfg, ch)],
|
|
||||||
[
|
|
||||||
["PID Config", "PID Auto Tune", "Run"],
|
|
||||||
partial(self.pid_auto_tune_request, ch),
|
|
||||||
],
|
|
||||||
]
|
|
||||||
for ch in range(self.NUM_CHANNELS)
|
|
||||||
]
|
|
||||||
self.thermostat.info_box_trigger.connect(self.info_box.display_info_box)
|
|
||||||
|
|
||||||
self.zero_limits_warning = ZeroLimitsWarningView(
|
|
||||||
self.style(), self.limits_warning
|
|
||||||
)
|
|
||||||
self.ctrl_panel_view = CtrlPanel(
|
|
||||||
[self.ch0_tree, self.ch1_tree],
|
|
||||||
get_ctrl_panel_config(args),
|
|
||||||
self.send_command,
|
|
||||||
param_tree_sigActivated_handles,
|
|
||||||
)
|
|
||||||
self.ctrl_panel_view.set_zero_limits_warning_sig.connect(
|
|
||||||
self.zero_limits_warning.set_limits_warning
|
|
||||||
)
|
|
||||||
|
|
||||||
self.thermostat.fan_update.connect(self.fan_update)
|
|
||||||
self.thermostat.report_update.connect(self.ctrl_panel_view.update_report)
|
|
||||||
self.thermostat.report_update.connect(self.autotuners.tick)
|
|
||||||
self.thermostat.report_update.connect(self.pid_autotune_handler)
|
|
||||||
self.thermostat.pid_update.connect(self.ctrl_panel_view.update_pid)
|
|
||||||
self.thermostat.pwm_update.connect(self.ctrl_panel_view.update_pwm)
|
|
||||||
self.thermostat.thermistor_update.connect(
|
|
||||||
self.ctrl_panel_view.update_thermistor
|
|
||||||
)
|
|
||||||
self.thermostat.postfilter_update.connect(
|
|
||||||
self.ctrl_panel_view.update_postfilter
|
|
||||||
)
|
|
||||||
self.thermostat.interval_update.connect(
|
|
||||||
self.autotuners.update_sampling_interval
|
|
||||||
)
|
|
||||||
self.report_apply_btn.clicked.connect(
|
|
||||||
lambda: self.thermostat.set_update_s(self.report_refresh_spin.value())
|
|
||||||
)
|
|
||||||
|
|
||||||
self.channel_graphs = LiveDataPlotter(
|
|
||||||
[
|
|
||||||
[getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")]
|
|
||||||
for ch in range(self.NUM_CHANNELS)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.thermostat.report_update.connect(self.channel_graphs.update_report)
|
|
||||||
self.thermostat.pid_update.connect(self.channel_graphs.update_pid)
|
|
||||||
|
|
||||||
self.plot_options_menu = PlotOptionsMenu()
|
|
||||||
self.plot_options_menu.clear.triggered.connect(self.clear_graphs)
|
|
||||||
self.plot_options_menu.samples_spinbox.valueChanged.connect(
|
|
||||||
self.channel_graphs.set_max_samples
|
|
||||||
)
|
|
||||||
self.plot_settings.setMenu(self.plot_options_menu)
|
|
||||||
|
|
||||||
self.conn_menu = ConnMenu()
|
|
||||||
self.connect_btn.setMenu(self.conn_menu)
|
|
||||||
|
|
||||||
self.thermostat_ctrl_menu = ThermostatCtrlMenu(self.style())
|
|
||||||
self.thermostat_ctrl_menu.fan_set_act.connect(self.fan_set_request)
|
|
||||||
self.thermostat_ctrl_menu.fan_auto_set_act.connect(self.fan_auto_set_request)
|
|
||||||
self.thermostat_ctrl_menu.reset_act.connect(self.reset_request)
|
|
||||||
self.thermostat_ctrl_menu.dfu_act.connect(self.dfu_request)
|
|
||||||
self.thermostat_ctrl_menu.save_cfg_act.connect(self.save_cfg_request)
|
|
||||||
self.thermostat_ctrl_menu.load_cfg_act.connect(self.load_cfg_request)
|
|
||||||
self.thermostat_ctrl_menu.net_cfg_act.connect(self.net_settings_request)
|
|
||||||
|
|
||||||
self.thermostat.hw_rev_update.connect(self.thermostat_ctrl_menu.hw_rev)
|
|
||||||
self.thermostat_settings.setMenu(self.thermostat_ctrl_menu)
|
|
||||||
|
|
||||||
self.loading_spinner.hide()
|
|
||||||
|
|
||||||
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 clear_graphs(self):
|
|
||||||
self.channel_graphs.clear_graphs()
|
|
||||||
|
|
||||||
async def _on_connection_changed(self, result):
|
|
||||||
self.graph_group.setEnabled(result)
|
|
||||||
self.report_group.setEnabled(result)
|
|
||||||
self.thermostat_settings.setEnabled(result)
|
|
||||||
|
|
||||||
self.conn_menu.host_set_line.setEnabled(not result)
|
|
||||||
self.conn_menu.port_set_spin.setEnabled(not result)
|
|
||||||
self.connect_btn.setText("Disconnect" if result else "Connect")
|
|
||||||
if result:
|
|
||||||
self.hw_rev_data = await self.thermostat.get_hw_rev()
|
|
||||||
logging.debug(self.hw_rev_data)
|
|
||||||
|
|
||||||
self._status(self.hw_rev_data)
|
|
||||||
self.thermostat.start_watching()
|
|
||||||
else:
|
|
||||||
self.status_lbl.setText("Disconnected")
|
|
||||||
self.background_task_lbl.setText("Ready.")
|
|
||||||
self.loading_spinner.hide()
|
|
||||||
self.loading_spinner.stop()
|
|
||||||
self.thermostat_ctrl_menu.fan_pwm_warning.setPixmap(QtGui.QPixmap())
|
|
||||||
self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("")
|
|
||||||
self.clear_graphs()
|
|
||||||
self.report_box.setChecked(False)
|
|
||||||
if not Thermostat.connecting or Thermostat.connected:
|
|
||||||
for ch in range(self.NUM_CHANNELS):
|
|
||||||
if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF:
|
|
||||||
await self.autotuners.stop_pid_from_running(ch)
|
|
||||||
await self.thermostat.set_report_mode(False)
|
|
||||||
self.thermostat.stop_watching()
|
|
||||||
|
|
||||||
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']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pyqtSlot("QVariantMap")
|
|
||||||
def fan_update(self, fan_settings):
|
|
||||||
logging.debug(fan_settings)
|
|
||||||
if fan_settings is None:
|
|
||||||
return
|
|
||||||
with QSignalBlocker(self.thermostat_ctrl_menu.fan_power_slider):
|
|
||||||
self.thermostat_ctrl_menu.fan_power_slider.setValue(
|
|
||||||
fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength
|
|
||||||
)
|
|
||||||
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
|
|
||||||
self.thermostat_ctrl_menu.fan_auto_box.setChecked(fan_settings["auto_mode"])
|
|
||||||
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
|
|
||||||
self.thermostat_ctrl_menu.set_fan_pwm_warning()
|
|
||||||
|
|
||||||
@asyncSlot(int)
|
|
||||||
async def on_report_box_stateChanged(self, enabled):
|
|
||||||
await self.thermostat.set_report_mode(enabled)
|
|
||||||
|
|
||||||
@asyncClose
|
|
||||||
async def closeEvent(self, event):
|
|
||||||
try:
|
|
||||||
await self.bail()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@asyncSlot()
|
|
||||||
async def on_connect_btn_clicked(self):
|
|
||||||
host, port = (
|
|
||||||
self.conn_menu.host_set_line.text(),
|
|
||||||
self.conn_menu.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.conn_menu.host_set_line.setEnabled(False)
|
|
||||||
self.conn_menu.port_set_spin.setEnabled(False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.client.start_session(host=host, port=port, timeout=5)
|
|
||||||
except StoppedConnecting:
|
|
||||||
return
|
|
||||||
await self._on_connection_changed(True)
|
|
||||||
else:
|
|
||||||
await self.bail()
|
|
||||||
|
|
||||||
# TODO: Remove asyncio.TimeoutError in Python 3.11
|
|
||||||
except (OSError, TimeoutError, asyncio.TimeoutError):
|
|
||||||
try:
|
|
||||||
await self.bail()
|
|
||||||
except ConnectionResetError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@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"""
|
|
||||||
ch = param.channel
|
|
||||||
|
|
||||||
for inner_param, change, data in changes:
|
|
||||||
if change == "value":
|
|
||||||
new_value = data
|
|
||||||
if "thermostat:set_param" in inner_param.opts:
|
|
||||||
if inner_param.opts.get("suffix", None) == "mA":
|
|
||||||
new_value /= 1000 # Given in mA
|
|
||||||
|
|
||||||
thermostat_param = inner_param.opts["thermostat:set_param"]
|
|
||||||
|
|
||||||
# Handle thermostat command irregularities
|
|
||||||
match inner_param.name(), new_value:
|
|
||||||
case "Postfilter Rate", None:
|
|
||||||
thermostat_param = thermostat_param.copy()
|
|
||||||
thermostat_param["field"] = "off"
|
|
||||||
new_value = ""
|
|
||||||
case "Control Method", "Constant Current":
|
|
||||||
return
|
|
||||||
case "Control Method", "Temperature PID":
|
|
||||||
new_value = ""
|
|
||||||
|
|
||||||
inner_param.setOpts(lock=True)
|
|
||||||
await self.client.set_param(
|
|
||||||
channel=ch, value=new_value, **thermostat_param
|
|
||||||
)
|
|
||||||
inner_param.setOpts(lock=False)
|
|
||||||
|
|
||||||
if "pid_autotune" in inner_param.opts:
|
|
||||||
auto_tuner_param = inner_param.opts["pid_autotune"]
|
|
||||||
self.autotuners.set_params(auto_tuner_param, ch, new_value)
|
|
||||||
|
|
||||||
@asyncSlot()
|
|
||||||
async def pid_auto_tune_request(self, ch=0):
|
|
||||||
match self.autotuners.get_state(ch):
|
|
||||||
case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED:
|
|
||||||
self.autotuners.load_params_and_set_ready(ch)
|
|
||||||
|
|
||||||
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
|
|
||||||
await self.autotuners.stop_pid_from_running(ch)
|
|
||||||
# To Update the UI elements
|
|
||||||
self.pid_autotune_handler([])
|
|
||||||
|
|
||||||
@asyncSlot(list)
|
|
||||||
async def pid_autotune_handler(self, _):
|
|
||||||
ch_tuning = []
|
|
||||||
for ch in range(self.NUM_CHANNELS):
|
|
||||||
match self.autotuners.get_state(ch):
|
|
||||||
case PIDAutotuneState.STATE_OFF:
|
|
||||||
self.ctrl_panel_view.change_params_title(
|
|
||||||
ch, ("PID Config", "PID Auto Tune", "Run"), "Run"
|
|
||||||
)
|
|
||||||
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
|
|
||||||
self.ctrl_panel_view.change_params_title(
|
|
||||||
ch, ("PID Config", "PID Auto Tune", "Run"), "Stop"
|
|
||||||
)
|
|
||||||
ch_tuning.append(ch)
|
|
||||||
|
|
||||||
case PIDAutotuneState.STATE_SUCCEEDED:
|
|
||||||
self.info_box.display_info_box(
|
|
||||||
"PID Autotune Success",
|
|
||||||
f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.",
|
|
||||||
)
|
|
||||||
self.info_box.show()
|
|
||||||
|
|
||||||
case PIDAutotuneState.STATE_FAILED:
|
|
||||||
self.info_box.display_info_box(
|
|
||||||
"PID Autotune Failed", f"Channel {ch} PID Autotune has failed."
|
|
||||||
)
|
|
||||||
self.info_box.show()
|
|
||||||
|
|
||||||
if len(ch_tuning) == 0:
|
|
||||||
self.background_task_lbl.setText("Ready.")
|
|
||||||
self.loading_spinner.hide()
|
|
||||||
self.loading_spinner.stop()
|
|
||||||
else:
|
|
||||||
self.background_task_lbl.setText(
|
|
||||||
"Autotuning channel {ch}...".format(ch=ch_tuning)
|
|
||||||
)
|
|
||||||
self.loading_spinner.start()
|
|
||||||
self.loading_spinner.show()
|
|
||||||
|
|
||||||
@asyncSlot(int)
|
|
||||||
async def fan_set_request(self, value):
|
|
||||||
if not self.client.connected():
|
|
||||||
return
|
|
||||||
if self.thermostat_ctrl_menu.fan_auto_box.isChecked():
|
|
||||||
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
|
|
||||||
self.thermostat_ctrl_menu.fan_auto_box.setChecked(False)
|
|
||||||
await self.client.set_fan(value)
|
|
||||||
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
|
|
||||||
self.thermostat_ctrl_menu.set_fan_pwm_warning()
|
|
||||||
|
|
||||||
@asyncSlot(int)
|
|
||||||
async def fan_auto_set_request(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.thermostat_ctrl_menu.fan_power_slider.value()
|
|
||||||
)
|
|
||||||
|
|
||||||
@asyncSlot(int)
|
|
||||||
async def save_cfg_request(self, ch):
|
|
||||||
await self.thermostat.save_cfg(str(ch))
|
|
||||||
|
|
||||||
@asyncSlot(int)
|
|
||||||
async def load_cfg_request(self, ch):
|
|
||||||
await self.thermostat.load_cfg(str(ch))
|
|
||||||
|
|
||||||
@asyncSlot(bool)
|
|
||||||
async def dfu_request(self, _):
|
|
||||||
await self._on_connection_changed(False)
|
|
||||||
await self.thermostat.dfu()
|
|
||||||
|
|
||||||
@asyncSlot(bool)
|
|
||||||
async def reset_request(self, _):
|
|
||||||
await self._on_connection_changed(False)
|
|
||||||
await self.thermostat.reset()
|
|
||||||
await asyncio.sleep(0.1) # Wait for the reset to start
|
|
||||||
|
|
||||||
self.connect_btn.click() # Reconnect
|
|
||||||
|
|
||||||
@asyncSlot(bool)
|
|
||||||
async def net_settings_request(self, _):
|
|
||||||
ipv4 = await self.thermostat.get_ipv4()
|
|
||||||
self.net_settings_input_diag = NetSettingsInputDiag(ipv4["addr"])
|
|
||||||
self.net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request)
|
|
||||||
|
|
||||||
@asyncSlot(str)
|
|
||||||
async def set_net_settings_request(self, ipv4_settings):
|
|
||||||
await self.thermostat.set_ipv4(ipv4_settings)
|
|
||||||
await self.thermostat._client.end_session()
|
|
||||||
await self._on_connection_changed(False)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
app.setWindowIcon(
|
|
||||||
QtGui.QIcon(str(importlib.resources.files("pytec.gui.resources").joinpath("artiq.ico")))
|
|
||||||
)
|
|
||||||
|
|
||||||
main_window = MainWindow(args)
|
|
||||||
main_window.show()
|
|
||||||
|
|
||||||
await app_quit_event.wait()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
qasync.run(coro_main())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
218
src/channels.rs
218
src/channels.rs
@ -20,15 +20,9 @@ use crate::{
|
|||||||
command_handler::JsonBuffer,
|
command_handler::JsonBuffer,
|
||||||
pins::{self, Channel0VRef, Channel1VRef},
|
pins::{self, Channel0VRef, Channel1VRef},
|
||||||
steinhart_hart,
|
steinhart_hart,
|
||||||
|
hw_rev,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub enum PinsAdcReadTarget {
|
|
||||||
VREF,
|
|
||||||
DacVfb,
|
|
||||||
ITec,
|
|
||||||
VTec,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const CHANNELS: usize = 2;
|
pub const CHANNELS: usize = 2;
|
||||||
pub const R_SENSE: f64 = 0.05;
|
pub const R_SENSE: f64 = 0.05;
|
||||||
|
|
||||||
@ -39,17 +33,18 @@ pub const MAX_TEC_I: f64 = 3.0;
|
|||||||
const DAC_OUT_V_MAX: f64 = 3.0;
|
const DAC_OUT_V_MAX: f64 = 3.0;
|
||||||
|
|
||||||
// TODO: -pub
|
// TODO: -pub
|
||||||
pub struct Channels {
|
pub struct Channels<'a> {
|
||||||
channel0: Channel<Channel0>,
|
channel0: Channel<Channel0>,
|
||||||
channel1: Channel<Channel1>,
|
channel1: Channel<Channel1>,
|
||||||
pub adc: ad7172::Adc<pins::AdcSpi, pins::AdcNss>,
|
pub adc: ad7172::Adc<pins::AdcSpi, pins::AdcNss>,
|
||||||
/// stm32f4 integrated adc
|
/// stm32f4 integrated adc
|
||||||
pins_adc: pins::PinsAdc,
|
pins_adc: pins::PinsAdc,
|
||||||
pub pwm: pins::PwmPins,
|
pub pwm: pins::PwmPins,
|
||||||
|
hwrev: &'a hw_rev::HWRev,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Channels {
|
impl<'a> Channels<'a> {
|
||||||
pub fn new(pins: pins::Pins) -> Self {
|
pub fn new(pins: pins::Pins, hwrev: &'a hw_rev::HWRev) -> Self {
|
||||||
let mut adc = ad7172::Adc::new(pins.adc_spi, pins.adc_nss).unwrap();
|
let mut adc = ad7172::Adc::new(pins.adc_spi, pins.adc_nss).unwrap();
|
||||||
// Feature not used
|
// Feature not used
|
||||||
adc.set_sync_enable(false).unwrap();
|
adc.set_sync_enable(false).unwrap();
|
||||||
@ -67,7 +62,7 @@ impl Channels {
|
|||||||
let channel1 = Channel::new(pins.channel1, adc_calibration1);
|
let channel1 = Channel::new(pins.channel1, adc_calibration1);
|
||||||
let pins_adc = pins.pins_adc;
|
let pins_adc = pins.pins_adc;
|
||||||
let pwm = pins.pwm;
|
let pwm = pins.pwm;
|
||||||
let mut channels = Channels { channel0, channel1, adc, pins_adc, pwm };
|
let mut channels = Channels { channel0, channel1, adc, pins_adc, pwm, hwrev };
|
||||||
for channel in 0..CHANNELS {
|
for channel in 0..CHANNELS {
|
||||||
channels.calibrate_dac_value(channel);
|
channels.calibrate_dac_value(channel);
|
||||||
channels.set_i(channel, ElectricCurrent::new::<ampere>(0.0));
|
channels.set_i(channel, ElectricCurrent::new::<ampere>(0.0));
|
||||||
@ -109,7 +104,7 @@ impl Channels {
|
|||||||
pub fn get_center(&mut self, channel: usize) -> ElectricPotential {
|
pub fn get_center(&mut self, channel: usize) -> ElectricPotential {
|
||||||
match self.channel_state(channel).center {
|
match self.channel_state(channel).center {
|
||||||
CenterPoint::Vref =>
|
CenterPoint::Vref =>
|
||||||
self.adc_read(channel, PinsAdcReadTarget::VREF, 8),
|
self.read_vref(channel),
|
||||||
CenterPoint::Override(center_point) =>
|
CenterPoint::Override(center_point) =>
|
||||||
ElectricPotential::new::<volt>(center_point.into()),
|
ElectricPotential::new::<volt>(center_point.into()),
|
||||||
}
|
}
|
||||||
@ -158,112 +153,32 @@ impl Channels {
|
|||||||
i_set
|
i_set
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AN4073: ADC Reading Dispersion can be reduced through Averaging
|
pub fn read_dac_feedback(&mut self, channel: usize) -> ElectricPotential {
|
||||||
pub fn adc_read(&mut self, channel: usize, adc_read_target: PinsAdcReadTarget, avg_pt: u16) -> ElectricPotential {
|
|
||||||
let mut sample: u32 = 0;
|
|
||||||
match channel {
|
match channel {
|
||||||
0 => {
|
0 => {
|
||||||
sample = match adc_read_target {
|
let sample = self.pins_adc.convert(
|
||||||
PinsAdcReadTarget::VREF => {
|
&self.channel0.dac_feedback_pin,
|
||||||
match &self.channel0.vref_pin {
|
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
|
||||||
Channel0VRef::Analog(vref_pin) => {
|
);
|
||||||
for _ in (0..avg_pt).rev() {
|
let mv = self.pins_adc.sample_to_millivolts(sample);
|
||||||
sample += self
|
|
||||||
.pins_adc
|
|
||||||
.convert(vref_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
|
|
||||||
as u32;
|
|
||||||
}
|
|
||||||
sample / avg_pt as u32
|
|
||||||
},
|
|
||||||
Channel0VRef::Disabled(_) => {2048 as u32}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PinsAdcReadTarget::DacVfb => {
|
|
||||||
for _ in (0..avg_pt).rev() {
|
|
||||||
sample += self
|
|
||||||
.pins_adc
|
|
||||||
.convert(&self.channel0.dac_feedback_pin,stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
|
|
||||||
as u32;
|
|
||||||
}
|
|
||||||
sample / avg_pt as u32
|
|
||||||
}
|
|
||||||
PinsAdcReadTarget::ITec => {
|
|
||||||
for _ in (0..avg_pt).rev() {
|
|
||||||
sample += self
|
|
||||||
.pins_adc
|
|
||||||
.convert(&self.channel0.itec_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
|
|
||||||
as u32;
|
|
||||||
}
|
|
||||||
sample / avg_pt as u32
|
|
||||||
}
|
|
||||||
PinsAdcReadTarget::VTec => {
|
|
||||||
for _ in (0..avg_pt).rev() {
|
|
||||||
sample += self
|
|
||||||
.pins_adc
|
|
||||||
.convert(&self.channel0.tec_u_meas_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
|
|
||||||
as u32;
|
|
||||||
}
|
|
||||||
sample / avg_pt as u32
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mv = self.pins_adc.sample_to_millivolts(sample as u16);
|
|
||||||
ElectricPotential::new::<millivolt>(mv as f64)
|
ElectricPotential::new::<millivolt>(mv as f64)
|
||||||
}
|
}
|
||||||
1 => {
|
1 => {
|
||||||
sample = match adc_read_target {
|
let sample = self.pins_adc.convert(
|
||||||
PinsAdcReadTarget::VREF => {
|
&self.channel1.dac_feedback_pin,
|
||||||
match &self.channel1.vref_pin {
|
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
|
||||||
Channel1VRef::Analog(vref_pin) => {
|
);
|
||||||
for _ in (0..avg_pt).rev() {
|
let mv = self.pins_adc.sample_to_millivolts(sample);
|
||||||
sample += self
|
|
||||||
.pins_adc
|
|
||||||
.convert(vref_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
|
|
||||||
as u32;
|
|
||||||
}
|
|
||||||
sample / avg_pt as u32
|
|
||||||
},
|
|
||||||
Channel1VRef::Disabled(_) => {2048 as u32}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PinsAdcReadTarget::DacVfb => {
|
|
||||||
for _ in (0..avg_pt).rev() {
|
|
||||||
sample += self
|
|
||||||
.pins_adc
|
|
||||||
.convert(&self.channel1.dac_feedback_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
|
|
||||||
as u32;
|
|
||||||
}
|
|
||||||
sample / avg_pt as u32
|
|
||||||
}
|
|
||||||
PinsAdcReadTarget::ITec => {
|
|
||||||
for _ in (0..avg_pt).rev() {
|
|
||||||
sample += self
|
|
||||||
.pins_adc
|
|
||||||
.convert(&self.channel1.itec_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
|
|
||||||
as u32;
|
|
||||||
}
|
|
||||||
sample / avg_pt as u32
|
|
||||||
}
|
|
||||||
PinsAdcReadTarget::VTec => {
|
|
||||||
for _ in (0..avg_pt).rev() {
|
|
||||||
sample += self
|
|
||||||
.pins_adc
|
|
||||||
.convert(&self.channel1.tec_u_meas_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
|
|
||||||
as u32;
|
|
||||||
}
|
|
||||||
sample / avg_pt as u32
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mv = self.pins_adc.sample_to_millivolts(sample as u16);
|
|
||||||
ElectricPotential::new::<millivolt>(mv as f64)
|
ElectricPotential::new::<millivolt>(mv as f64)
|
||||||
}
|
}
|
||||||
_ => unreachable!()
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_dac_feedback_until_stable(&mut self, channel: usize, tolerance: ElectricPotential) -> ElectricPotential {
|
pub fn read_dac_feedback_until_stable(&mut self, channel: usize, tolerance: ElectricPotential) -> ElectricPotential {
|
||||||
let mut prev = self.adc_read(channel, PinsAdcReadTarget::DacVfb, 1);
|
let mut prev = self.read_dac_feedback(channel);
|
||||||
loop {
|
loop {
|
||||||
let current = self.adc_read(channel, PinsAdcReadTarget::DacVfb, 1);
|
let current = self.read_dac_feedback(channel);
|
||||||
if (current - prev).abs() < tolerance {
|
if (current - prev).abs() < tolerance {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
@ -271,6 +186,83 @@ impl Channels {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_itec(&mut self, channel: usize) -> ElectricPotential {
|
||||||
|
match channel {
|
||||||
|
0 => {
|
||||||
|
let sample = self.pins_adc.convert(
|
||||||
|
&self.channel0.itec_pin,
|
||||||
|
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
|
||||||
|
);
|
||||||
|
let mv = self.pins_adc.sample_to_millivolts(sample);
|
||||||
|
ElectricPotential::new::<millivolt>(mv as f64)
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let sample = self.pins_adc.convert(
|
||||||
|
&self.channel1.itec_pin,
|
||||||
|
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
|
||||||
|
);
|
||||||
|
let mv = self.pins_adc.sample_to_millivolts(sample);
|
||||||
|
ElectricPotential::new::<millivolt>(mv as f64)
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// should be 1.5V
|
||||||
|
pub fn read_vref(&mut self, channel: usize) -> ElectricPotential {
|
||||||
|
match channel {
|
||||||
|
0 => {
|
||||||
|
match &self.channel0.vref_pin {
|
||||||
|
Channel0VRef::Analog(vref_pin) => {
|
||||||
|
let sample = self.pins_adc.convert(
|
||||||
|
vref_pin,
|
||||||
|
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
|
||||||
|
);
|
||||||
|
let mv = self.pins_adc.sample_to_millivolts(sample);
|
||||||
|
ElectricPotential::new::<millivolt>(mv as f64)
|
||||||
|
},
|
||||||
|
Channel0VRef::Disabled(_) => ElectricPotential::new::<volt>(1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
match &self.channel1.vref_pin {
|
||||||
|
Channel1VRef::Analog(vref_pin) => {
|
||||||
|
let sample = self.pins_adc.convert(
|
||||||
|
vref_pin,
|
||||||
|
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
|
||||||
|
);
|
||||||
|
let mv = self.pins_adc.sample_to_millivolts(sample);
|
||||||
|
ElectricPotential::new::<millivolt>(mv as f64)
|
||||||
|
},
|
||||||
|
Channel1VRef::Disabled(_) => ElectricPotential::new::<volt>(1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_tec_u_meas(&mut self, channel: usize) -> ElectricPotential {
|
||||||
|
match channel {
|
||||||
|
0 => {
|
||||||
|
let sample = self.pins_adc.convert(
|
||||||
|
&self.channel0.tec_u_meas_pin,
|
||||||
|
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
|
||||||
|
);
|
||||||
|
let mv = self.pins_adc.sample_to_millivolts(sample);
|
||||||
|
ElectricPotential::new::<millivolt>(mv as f64)
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let sample = self.pins_adc.convert(
|
||||||
|
&self.channel1.tec_u_meas_pin,
|
||||||
|
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
|
||||||
|
);
|
||||||
|
let mv = self.pins_adc.sample_to_millivolts(sample);
|
||||||
|
ElectricPotential::new::<millivolt>(mv as f64)
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Calibrates the DAC output to match vref of the MAX driver to reduce zero-current offset of the MAX driver output.
|
/// Calibrates the DAC output to match vref of the MAX driver to reduce zero-current offset of the MAX driver output.
|
||||||
///
|
///
|
||||||
/// The thermostat DAC applies a control voltage signal to the CTLI pin of MAX driver chip to control its output current.
|
/// The thermostat DAC applies a control voltage signal to the CTLI pin of MAX driver chip to control its output current.
|
||||||
@ -398,12 +390,12 @@ impl Channels {
|
|||||||
|
|
||||||
// Get current passing through TEC
|
// Get current passing through TEC
|
||||||
pub fn get_tec_i(&mut self, channel: usize) -> ElectricCurrent {
|
pub fn get_tec_i(&mut self, channel: usize) -> ElectricCurrent {
|
||||||
(self.adc_read(channel, PinsAdcReadTarget::ITec, 16) - self.adc_read(channel, PinsAdcReadTarget::VREF, 16)) / ElectricalResistance::new::<ohm>(0.4)
|
(self.read_itec(channel) - self.read_vref(channel)) / ElectricalResistance::new::<ohm>(0.4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get voltage across TEC
|
// Get voltage across TEC
|
||||||
pub fn get_tec_v(&mut self, channel: usize) -> ElectricPotential {
|
pub fn get_tec_v(&mut self, channel: usize) -> ElectricPotential {
|
||||||
(self.adc_read(channel, PinsAdcReadTarget::VTec, 16) - ElectricPotential::new::<volt>(1.5)) * 4.0
|
(self.read_tec_u_meas(channel) - ElectricPotential::new::<volt>(1.5)) * 4.0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_pwm(&mut self, channel: usize, pin: PwmPin, duty: f64) -> f64 {
|
fn set_pwm(&mut self, channel: usize, pin: PwmPin, duty: f64) -> f64 {
|
||||||
@ -456,8 +448,8 @@ impl Channels {
|
|||||||
|
|
||||||
fn report(&mut self, channel: usize) -> Report {
|
fn report(&mut self, channel: usize) -> Report {
|
||||||
let i_set = self.get_i(channel);
|
let i_set = self.get_i(channel);
|
||||||
let i_tec = self.adc_read(channel, PinsAdcReadTarget::ITec, 16);
|
let i_tec = if self.hwrev.major > 2 {Some(self.read_itec(channel))} else {None};
|
||||||
let tec_i = self.get_tec_i(channel);
|
let tec_i = if self.hwrev.major > 2 {Some(self.get_tec_i(channel))} else {None};
|
||||||
let dac_value = self.get_dac(channel);
|
let dac_value = self.get_dac(channel);
|
||||||
let state = self.channel_state(channel);
|
let state = self.channel_state(channel);
|
||||||
let pid_output = ElectricCurrent::new::<ampere>(state.pid.y1);
|
let pid_output = ElectricCurrent::new::<ampere>(state.pid.y1);
|
||||||
@ -472,7 +464,7 @@ impl Channels {
|
|||||||
pid_engaged: state.pid_engaged,
|
pid_engaged: state.pid_engaged,
|
||||||
i_set,
|
i_set,
|
||||||
dac_value,
|
dac_value,
|
||||||
dac_feedback: self.adc_read(channel, PinsAdcReadTarget::DacVfb, 1),
|
dac_feedback: self.read_dac_feedback(channel),
|
||||||
i_tec,
|
i_tec,
|
||||||
tec_i,
|
tec_i,
|
||||||
tec_u_meas: self.get_tec_v(channel),
|
tec_u_meas: self.get_tec_v(channel),
|
||||||
@ -570,8 +562,8 @@ pub struct Report {
|
|||||||
i_set: ElectricCurrent,
|
i_set: ElectricCurrent,
|
||||||
dac_value: ElectricPotential,
|
dac_value: ElectricPotential,
|
||||||
dac_feedback: ElectricPotential,
|
dac_feedback: ElectricPotential,
|
||||||
i_tec: ElectricPotential,
|
i_tec: Option<ElectricPotential>,
|
||||||
tec_i: ElectricCurrent,
|
tec_i: Option<ElectricCurrent>,
|
||||||
tec_u_meas: ElectricPotential,
|
tec_u_meas: ElectricPotential,
|
||||||
pid_output: ElectricCurrent,
|
pid_output: ElectricCurrent,
|
||||||
}
|
}
|
||||||
|
@ -138,7 +138,7 @@ fn main() -> ! {
|
|||||||
|
|
||||||
let mut store = flash_store::store(dp.FLASH);
|
let mut store = flash_store::store(dp.FLASH);
|
||||||
|
|
||||||
let mut channels = Channels::new(pins);
|
let mut channels = Channels::new(pins, &hwrev);
|
||||||
for c in 0..CHANNELS {
|
for c in 0..CHANNELS {
|
||||||
match store.read_value::<ChannelConfig>(CHANNEL_CONFIG_KEY[c]) {
|
match store.read_value::<ChannelConfig>(CHANNEL_CONFIG_KEY[c]) {
|
||||||
Ok(Some(config)) =>
|
Ok(Some(config)) =>
|
||||||
|
Loading…
Reference in New Issue
Block a user